refactor(server): move health, discovery and consent-COOP behind the container

This commit is contained in:
jubnl
2026-08-11 22:23:51 +02:00
parent a5202f418b
commit 1390bfbe06
15 changed files with 576 additions and 264 deletions
+9
View File
@@ -11,6 +11,8 @@ import { apiDocsEnabled } from './nest/common/api-docs.kill-switch';
import { setupApiDocs } from './nest/platform/api-docs';
import { McpRegistryService } from '@trek/nest-mcp';
import { setMcpRegistry } from './mcp/registry-handoff';
import { MCP_METADATA_MIDDLEWARE } from './nest/platform/mcp-metadata.middleware';
import type { RequestHandler } from 'express';
import { validateBodyContracts } from './nest/common/validate-body-contracts';
import { validateRouteGuards } from './nest/common/validate-route-guards';
import { TrekWsAdapter } from './nest/realtime/trek-ws.adapter';
@@ -82,6 +84,13 @@ export async function buildApp(): Promise<INestApplication> {
applyGlobalMiddleware(instance, { http });
applyPlatformUploads(instance);
applyPlatformTransport(instance);
// The SDK discovery router (+ its addon gate). Container-built so its deps
// are injected (same pre-init consumption bridge as httpConfig above), but
// applied here as a PATHLESS app.use: the SDK router matches absolute
// /.well-known/* paths against req.url, and any Nest wildcard forRoutes()
// mount is an Express pattern mount that strips the matched prefix from
// req.url before the middleware runs.
instance.use(app.get<RequestHandler>(MCP_METADATA_MIDDLEWARE));
applyPlatformStatic(instance);
// Pin the request-body ceiling explicitly. The Express shell used to set
// '100kb' and stopped doing it when the Nest instance took over parsing, which
+2 -1
View File
@@ -5,6 +5,7 @@ import { AppConfigModule } from './app-config/app-config.module';
import { DatabaseModule } from './database/database.module';
import { RealtimeModule } from './realtime/realtime.module';
import { HealthModule } from './health/health.module';
import { PlatformModule } from './platform/platform.module';
import { GlobalAuthGuard } from './auth/global-auth.guard';
import { MfaPolicyGuard } from './auth/mfa-policy.guard';
import { WeatherModule } from './weather/weather.module';
@@ -69,7 +70,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated.
*/
@Module({
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule],
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
@@ -80,7 +80,15 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [
'AuthPublicController.resetPassword',
'AuthPublicController.verifyMfaLogin',
'ConfigController.getConfig',
// OAuth/OIDC discovery documents + the JSON 404 catchalls that keep
// /.well-known probes from ever seeing SPA HTML.
'DiscoveryController.openidConfiguration',
'DiscoveryController.protectedResource',
'DiscoveryController.wellKnownFallback',
'DiscoveryController.wellKnownRoot',
'FeaturesController.features',
// The container/uptime probe.
'FeaturesController.health',
// The download link carries its own short-lived token.
'FilesDownloadController.download',
'HelpController.asset',
+13 -2
View File
@@ -1,10 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, Res } from '@nestjs/common';
import type { Response } from 'express';
import { KitineraryExtractorService } from '../booking-import/kitinerary-extractor.service';
import { AddonsService } from '../addons/addons.service';
import { ADDON_IDS } from '../../addons';
import { Public } from '../auth/public.decorator';
/** Exposes server feature flags consumed by the frontend to show/hide optional UI. */
/** Exposes the container probe and the server feature flags consumed by the
* frontend to show/hide optional UI. */
@Public('server capability flags the login screen reads to decide what to offer')
@Controller('api/health')
export class FeaturesController {
@@ -13,6 +15,15 @@ export class FeaturesController {
private readonly addons: AddonsService,
) {}
/** The container/uptime probe. The forced-HTTPS redirect and HSTS exempt this
* path inside globalMiddleware, so probes work regardless of proxy setup;
* @Res() keeps the exact legacy header casing and body bytes. */
@Get()
health(@Res() res: Response): void {
res.setHeader('Cache-Control', 'no-store, must-revalidate');
res.json({ status: 'ok' });
}
@Get('features')
features() {
return {
+3 -3
View File
@@ -5,9 +5,9 @@ import { AddonsModule } from '../addons/addons.module';
/** Server capability reporting. `GET /api/health/features` tells the client which
* optional server-side features are usable, so it can hide the affordances it
* cannot back. `GET /api/health` itself is the container probe and stays on the
* raw express layer (`platform.routes.ts`) — it must answer before Nest routing
* and before the HTTPS redirect. */
* cannot back. `GET /api/health` is the container probe — the forced-HTTPS
* redirect and HSTS exempt it by path inside globalMiddleware, so it answers
* plain-HTTP probes no matter where the route itself is registered. */
@Module({
imports: [KitineraryExtractorModule, AddonsModule],
controllers: [FeaturesController],
@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import type { NestMiddleware } from '@nestjs/common';
import type { Request, Response, NextFunction } from 'express';
/**
* 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 —
* so /oauth/consent relaxes it. Runs after helmet (Nest middleware registers
* during init, helmet is pre-init) and simply overwrites the header, exactly
* like the pre-init override it replaces.
*/
@Injectable()
export class ConsentCoopMiddleware implements NestMiddleware {
use(_req: Request, res: Response, next: NextFunction): void {
res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
next();
}
}
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import type express from 'express';
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
import { ALL_SCOPES } from '../../mcp/scopes';
import { getMcpSafeUrl } from '../../app-config';
/**
* OAuth/MCP discovery metadata, built lazily on first request so the issuer URL
* is resolved from the live env, not frozen at buildApp() time. One instance per
* app (DI singleton) replaces the per-applyPlatformTransport-call closures the
* pre-init Express mount used — same laziness, same per-app cache scope.
*
* 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)
*/
@Injectable()
export class DiscoveryMetadataService {
private oauthMetadata: OAuthMetadata | null = null;
private sdkMetaRouter: express.Router | null = null;
getOAuthMetadata(): OAuthMetadata {
if (this.oauthMetadata) return this.oauthMetadata;
const base = getMcpSafeUrl().replace(/\/+$/, '');
this.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 this.oauthMetadata;
}
getMetaRouter(): express.Router {
if (this.sdkMetaRouter) return this.sdkMetaRouter;
const metadata = this.getOAuthMetadata();
this.sdkMetaRouter = mcpAuthMetadataRouter({
oauthMetadata: metadata,
resourceServerUrl: new URL(`${metadata.issuer}/mcp`),
scopesSupported: ALL_SCOPES as string[],
resourceName: 'TREK MCP',
});
return this.sdkMetaRouter;
}
}
@@ -0,0 +1,85 @@
import { All, Controller, Get, NotFoundException, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { Public } from '../auth/public.decorator';
import { DiscoveryMetadataService } from './discovery-metadata.service';
import { AddonsService } from '../addons/addons.service';
import { ADDON_IDS } from '../../addons';
import { ALL_SCOPES } from '../../mcp/scopes';
/**
* The hand-rolled halves of OAuth discovery (the SDK metadata router itself is
* the pre-init McpMetadataMiddleware). Every handler writes through @Res() and
* never throws for its own paths: a thrown NotFoundException would reach
* SpaFallbackFilter, which serves index.html for any unmatched production GET —
* the exact "does not implement OAuth" bug the JSON catchall exists to prevent.
*
* Route declaration order is load-bearing (Nest registers in method order):
* the two concrete documents come before the catchalls.
*/
@Public('OAuth/OIDC discovery metadata read by MCP clients before any session exists')
@Controller('.well-known')
export class DiscoveryController {
constructor(
private readonly meta: DiscoveryMetadataService,
private readonly addons: AddonsService,
) {}
// 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.
@Get('openid-configuration')
openidConfiguration(@Res() res: Response): void {
const meta = this.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.
@Get('oauth-protected-resource')
protectedResource(@Res() res: Response): void {
if (!this.addons.isAddonEnabled(ADDON_IDS.MCP)) {
res.status(404).end();
return;
}
const meta = this.meta.getOAuthMetadata();
res.json({
resource: `${meta.issuer}/mcp`,
authorization_servers: [meta.issuer],
bearer_methods_supported: ['header'],
scopes_supported: ALL_SCOPES,
resource_name: 'TREK MCP',
});
}
// '/.well-known' and '/.well-known/' both land here (Express strict:false).
// The trailing-slash form was inside the legacy catchall's startsWith match →
// JSON 404. The bare form was NOT — it fell through to the router 404
// (SpaFallbackFilter: index.html on production GETs, JSON envelope otherwise),
// so the throw below deliberately reproduces the framework message and route.
@All('/')
wellKnownRoot(@Req() req: Request, @Res() res: Response): void {
if (req.path === '/.well-known/') {
res.status(404).json({ error: 'not_found' });
return;
}
throw new NotFoundException(`Cannot ${req.method} ${req.originalUrl}`);
}
// 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".
@All('*path')
wellKnownFallback(@Res() res: Response): void {
res.status(404).json({ error: 'not_found' });
}
}
@@ -0,0 +1,39 @@
import type { RequestHandler } from 'express';
import { DiscoveryMetadataService } from './discovery-metadata.service';
import { AddonsService } from '../addons/addons.service';
import { ADDON_IDS } from '../../addons';
/**
* The SDK discovery router plus its addon gate: 404 (empty body) on every
* /.well-known/* path while the MCP addon is off (M2 — prevents feature
* fingerprinting), otherwise delegate to the lazily built metadata router.
*
* NOT registered through MiddlewareConsumer: the SDK router matches absolute
* /.well-known/* paths against req.url, and a Nest wildcard forRoutes() mount
* is an Express pattern mount that strips the matched prefix from req.url
* before the middleware runs. bootstrap.ts applies this pre-init as a pathless
* app.use — the only mount shape that leaves req.url untouched — resolving it
* from the container by token (the httpConfig.KEY precedent). A factory
* provider rather than a NestMiddleware class because nothing consumer-mounts
* it: the deliverable is the bare Express RequestHandler itself.
*/
export const MCP_METADATA_MIDDLEWARE = Symbol('MCP_METADATA_MIDDLEWARE');
export function createMcpMetadataMiddleware(
meta: DiscoveryMetadataService,
addons: AddonsService,
): RequestHandler {
return (req, res, next) => {
if (req.path.startsWith('/.well-known/') && !addons.isAddonEnabled(ADDON_IDS.MCP)) {
res.status(404).end();
return;
}
meta.getMetaRouter()(req, res, next);
};
}
export const mcpMetadataMiddlewareProvider = {
provide: MCP_METADATA_MIDDLEWARE,
useFactory: createMcpMetadataMiddleware,
inject: [DiscoveryMetadataService, AddonsService],
};
@@ -0,0 +1,35 @@
import { Module, type MiddlewareConsumer, type NestModule } from '@nestjs/common';
import { AddonsModule } from '../addons/addons.module';
import { DiscoveryController } from './discovery.controller';
import { DiscoveryMetadataService } from './discovery-metadata.service';
import { mcpMetadataMiddlewareProvider } from './mcp-metadata.middleware';
import { ConsentCoopMiddleware } from './consent-coop.middleware';
/**
* The platform surface that lives behind the container: OAuth/OIDC discovery
* (controller + SDK metadata middleware) and the /oauth/consent COOP override.
* What remains pre-init in platform.routes.ts is path-prefixed static serving
* (uploads, built client) plus — until the /mcp migration lands — the OAuth SDK
* and /mcp transport mounts.
*
* This module is the repo's first MiddlewareConsumer. Express middleware that
* needs DI has exactly two honest mounts:
* - consumer.apply(...).forRoutes('<concrete path>') — an Express prefix
* mount, identical semantics to the legacy app.use('/path', fn) calls
* (ConsentCoopMiddleware below);
* - a pathless app.use in bootstrap for middleware that must see the
* original req.url (MCP_METADATA_MIDDLEWARE — a wildcard forRoutes()
* would strip the matched prefix and break the SDK router's matching).
*/
@Module({
imports: [AddonsModule],
controllers: [DiscoveryController],
providers: [DiscoveryMetadataService, mcpMetadataMiddlewareProvider, ConsentCoopMiddleware],
})
export class PlatformModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
// Prefix mount: covers /oauth/consent and any sub-path, like the legacy
// app.use('/oauth/consent', ...) it replaces.
consumer.apply(ConsentCoopMiddleware).forRoutes('oauth/consent');
}
}
+5 -107
View File
@@ -9,12 +9,8 @@ import { mcpHandler } from '../../mcp';
import { trekOAuthProvider, trekClientsStore } from '../../mcp/oauthProvider';
import { isAddonEnabled } from '../addons/addons.bridge';
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 '../../app-config';
// 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).
@@ -104,21 +100,13 @@ export function applyPlatformUploads(app: express.Application): void {
}
/**
* 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.
* The OAuth SDK + /mcp transport mounts still living on the pre-init Express
* layer. The rest of the former transport surface is behind the container now:
* /api/health (FeaturesController), OAuth discovery (DiscoveryController + the
* McpMetadataMiddleware bootstrap applies pre-init), and the /oauth/consent
* COOP override (ConsentCoopMiddleware via PlatformModule.configure).
*/
export function applyPlatformTransport(app: express.Application): void {
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) => {
@@ -126,81 +114,6 @@ export function applyPlatformTransport(app: express.Application): void {
next();
};
// SDK metadata router — built lazily on first request so the issuer URL is
// resolved from the live env, not frozen at createApp() time.
// 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 }));
@@ -212,21 +125,6 @@ export function applyPlatformTransport(app: express.Application): void {
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();
});
}
/**