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();
});
}
/**
+82
View File
@@ -59,6 +59,7 @@ import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { createOAuthClient, getUserByAccessToken } from '../../src/nest/oauth/oauth.bridge';
import { ALL_SCOPES } from '../../src/mcp/scopes';
import { OauthService } from '../../src/nest/oauth/oauth.service';
import { DatabaseService } from '../../src/nest/database/database.service';
import { AddonsService } from '../../src/nest/addons/addons.service';
@@ -164,6 +165,87 @@ describe('DCR scope optional — ChatGPT compatibility (issue #959 bug 2)', () =
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Platform/discovery parity pins — byte-level oracles for the MCP/OAuth mount
// migration (these must pass identically before AND after the routes move
// behind the Nest container).
// ─────────────────────────────────────────────────────────────────────────────
describe('platform/discovery parity pins', () => {
it('PLAT-PIN-001 — GET /api/health returns the exact probe body + Cache-Control', async () => {
const res = await request(app).get('/api/health');
expect(res.status).toBe(200);
expect(res.headers['cache-control']).toBe('no-store, must-revalidate');
expect(res.body).toEqual({ status: 'ok' });
});
it('PLAT-PIN-002 — openid-configuration is the AS metadata plus userinfo_endpoint, exactly', async () => {
const res = await request(app).get('/.well-known/openid-configuration');
expect(res.status).toBe(200);
expect(res.body).toEqual({
issuer: 'https://trek.example.com',
authorization_endpoint: 'https://trek.example.com/oauth/authorize',
token_endpoint: 'https://trek.example.com/oauth/token',
revocation_endpoint: 'https://trek.example.com/oauth/revoke',
registration_endpoint: 'https://trek.example.com/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,
userinfo_endpoint: 'https://trek.example.com/oauth/userinfo',
});
});
it('PLAT-PIN-003 — flat oauth-protected-resource is the exact 5-key RFC 9728 document', async () => {
const res = await request(app).get('/.well-known/oauth-protected-resource');
expect(res.status).toBe(200);
expect(res.body).toEqual({
resource: 'https://trek.example.com/mcp',
authorization_servers: ['https://trek.example.com'],
bearer_methods_supported: ['header'],
scopes_supported: ALL_SCOPES,
resource_name: 'TREK MCP',
});
});
it('PLAT-PIN-004 — every /.well-known/* path 404s with an EMPTY body when MCP is disabled', async () => {
setMcpEnabled(false);
for (const path of [
'/.well-known/openid-configuration',
'/.well-known/oauth-protected-resource',
'/.well-known/oauth-authorization-server',
]) {
const res = await request(app).get(path);
expect(res.status, path).toBe(404);
expect(res.text, path).toBe('');
}
});
it('PLAT-PIN-005 — an unhandled /.well-known path gets the JSON 404, never SPA HTML', async () => {
const res = await request(app).get('/.well-known/does-not-exist');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'not_found' });
});
it('PLAT-PIN-006 — /.well-known/ (trailing slash) also gets the JSON 404', async () => {
const res = await request(app).get('/.well-known/');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'not_found' });
});
it('PLAT-PIN-007 — bare /.well-known falls through to the router 404 envelope', async () => {
const res = await request(app).get('/.well-known');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Cannot GET /.well-known' });
});
it('PLAT-PIN-008 — /oauth/consent responses carry the relaxed COOP header', async () => {
const res = await request(app).get('/oauth/consent');
expect(res.headers['cross-origin-opener-policy']).toBe('unsafe-none');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// POST /oauth/token — authorization_code grant
// ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NotFoundException } from '@nestjs/common';
const h = vi.hoisted(() => ({
getMcpSafeUrl: vi.fn(() => 'https://trek.example.test'),
// The SDK factory returns a tagged middleware so tests can identify delegation
// without running real router wiring.
metaRouter: vi.fn(),
}));
vi.mock('../../../src/app-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/app-config')>();
return { ...actual, getMcpSafeUrl: h.getMcpSafeUrl };
});
vi.mock('@modelcontextprotocol/sdk/server/auth/router', () => ({
mcpAuthMetadataRouter: vi.fn(() => h.metaRouter),
}));
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
import { DiscoveryMetadataService } from '../../../src/nest/platform/discovery-metadata.service';
import {
MCP_METADATA_MIDDLEWARE,
createMcpMetadataMiddleware,
mcpMetadataMiddlewareProvider,
} from '../../../src/nest/platform/mcp-metadata.middleware';
import { ConsentCoopMiddleware } from '../../../src/nest/platform/consent-coop.middleware';
import { DiscoveryController } from '../../../src/nest/platform/discovery.controller';
import { PlatformModule } from '../../../src/nest/platform/platform.module';
import { ALL_SCOPES } from '../../../src/mcp/scopes';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
function makeRes() {
const res = {
statusCode: 200,
body: undefined as unknown,
headers: {} as Record<string, string>,
ended: false,
status: vi.fn(function (this: typeof res, c: number) { this.statusCode = c; return this; }),
json: vi.fn(function (this: typeof res, b: unknown) { this.body = b; return this; }),
end: vi.fn(function (this: typeof res) { this.ended = true; return this; }),
setHeader: vi.fn(function (this: typeof res, k: string, v: string) { this.headers[k] = v; return this; }),
};
return res;
}
function addons(enabled: boolean) {
return { isAddonEnabled: vi.fn(() => enabled) } as unknown as AddonsService;
}
beforeEach(() => {
vi.clearAllMocks();
h.getMcpSafeUrl.mockReturnValue('https://trek.example.test');
});
describe('DiscoveryMetadataService', () => {
it('DISC-001: builds the RFC 8414 AS metadata from the live safe URL', () => {
const meta = new DiscoveryMetadataService().getOAuthMetadata();
expect(meta).toEqual({
issuer: 'https://trek.example.test',
authorization_endpoint: 'https://trek.example.test/oauth/authorize',
token_endpoint: 'https://trek.example.test/oauth/token',
revocation_endpoint: 'https://trek.example.test/oauth/revoke',
registration_endpoint: 'https://trek.example.test/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,
});
});
it('DISC-002: trims trailing slashes off the configured base URL', () => {
h.getMcpSafeUrl.mockReturnValue('https://trek.example.test///');
expect(new DiscoveryMetadataService().getOAuthMetadata().issuer).toBe('https://trek.example.test');
});
it('DISC-003: caches the metadata after the first lazy build', () => {
const svc = new DiscoveryMetadataService();
const first = svc.getOAuthMetadata();
expect(svc.getOAuthMetadata()).toBe(first);
expect(h.getMcpSafeUrl).toHaveBeenCalledTimes(1);
});
it('DISC-004: builds the SDK metadata router once, pointing at the /mcp resource', () => {
const svc = new DiscoveryMetadataService();
expect(svc.getMetaRouter()).toBe(h.metaRouter);
expect(svc.getMetaRouter()).toBe(h.metaRouter);
expect(mcpAuthMetadataRouter).toHaveBeenCalledTimes(1);
const opts = vi.mocked(mcpAuthMetadataRouter).mock.calls[0][0];
expect(opts.resourceServerUrl.href).toBe('https://trek.example.test/mcp');
expect(opts.resourceName).toBe('TREK MCP');
expect(opts.scopesSupported).toEqual(ALL_SCOPES);
});
});
describe('createMcpMetadataMiddleware', () => {
it('DISC-010: 404s a /.well-known path with an empty body when MCP is disabled', () => {
const mw = createMcpMetadataMiddleware(new DiscoveryMetadataService(), addons(false));
const res = makeRes();
const next = vi.fn();
mw({ path: '/.well-known/oauth-authorization-server' } as never, res as never, next);
expect(res.statusCode).toBe(404);
expect(res.ended).toBe(true);
expect(next).not.toHaveBeenCalled();
expect(h.metaRouter).not.toHaveBeenCalled();
});
it('DISC-011: delegates a /.well-known path to the SDK router when MCP is enabled', () => {
const mw = createMcpMetadataMiddleware(new DiscoveryMetadataService(), addons(true));
const next = vi.fn();
mw({ path: '/.well-known/oauth-authorization-server' } as never, makeRes() as never, next);
expect(h.metaRouter).toHaveBeenCalled();
});
it('DISC-012: delegates non-well-known paths without consulting the addon gate', () => {
const gate = addons(false);
const mw = createMcpMetadataMiddleware(new DiscoveryMetadataService(), gate);
mw({ path: '/api/trips' } as never, makeRes() as never, vi.fn());
expect(gate.isAddonEnabled).not.toHaveBeenCalled();
expect(h.metaRouter).toHaveBeenCalled();
});
it('DISC-013: the factory provider wires the token to the factory with its two deps', () => {
expect(mcpMetadataMiddlewareProvider.provide).toBe(MCP_METADATA_MIDDLEWARE);
expect(mcpMetadataMiddlewareProvider.useFactory).toBe(createMcpMetadataMiddleware);
expect(mcpMetadataMiddlewareProvider.inject.length).toBe(2);
});
});
describe('ConsentCoopMiddleware', () => {
it('DISC-020: relaxes COOP then continues', () => {
const res = makeRes();
const next = vi.fn();
new ConsentCoopMiddleware().use({} as never, res as never, next);
expect(res.headers['Cross-Origin-Opener-Policy']).toBe('unsafe-none');
expect(next).toHaveBeenCalled();
});
});
describe('DiscoveryController', () => {
function controller(enabled = true) {
return new DiscoveryController(new DiscoveryMetadataService(), addons(enabled));
}
it('DISC-030: openid-configuration is the AS metadata plus userinfo_endpoint', () => {
const res = makeRes();
controller().openidConfiguration(res as never);
const body = res.body as { issuer: string; userinfo_endpoint: string };
expect(body.issuer).toBe('https://trek.example.test');
expect(body.userinfo_endpoint).toBe('https://trek.example.test/oauth/userinfo');
});
it('DISC-031: flat oauth-protected-resource 404s empty when MCP is disabled', () => {
const res = makeRes();
controller(false).protectedResource(res as never);
expect(res.statusCode).toBe(404);
expect(res.ended).toBe(true);
});
it('DISC-032: flat oauth-protected-resource serves the PRM document when enabled', () => {
const res = makeRes();
controller().protectedResource(res as never);
expect(res.body).toEqual({
resource: 'https://trek.example.test/mcp',
authorization_servers: ['https://trek.example.test'],
bearer_methods_supported: ['header'],
scopes_supported: ALL_SCOPES,
resource_name: 'TREK MCP',
});
});
it('DISC-033: the catchall answers 404 JSON, never throwing into the SPA fallback', () => {
const res = makeRes();
controller().wellKnownFallback(res as never);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'not_found' });
});
it('DISC-034: /.well-known/ (trailing slash) gets the same 404 JSON', () => {
const res = makeRes();
controller().wellKnownRoot({ path: '/.well-known/' } as never, res as never);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'not_found' });
});
it('DISC-035: bare /.well-known reproduces the framework 404 (SpaFallback route)', () => {
const req = { path: '/.well-known', method: 'GET', originalUrl: '/.well-known' };
expect(() => controller().wellKnownRoot(req as never, makeRes() as never))
.toThrowError(new NotFoundException('Cannot GET /.well-known'));
});
it('DISC-036: route declaration order keeps the concrete documents ahead of the catchalls', () => {
// Nest registers routes in method-declaration order; the catchall must not
// shadow the concrete documents.
const names = Object.getOwnPropertyNames(DiscoveryController.prototype).filter((n) => n !== 'constructor');
expect(names.indexOf('openidConfiguration')).toBeLessThan(names.indexOf('wellKnownRoot'));
expect(names.indexOf('protectedResource')).toBeLessThan(names.indexOf('wellKnownRoot'));
expect(names.indexOf('wellKnownRoot')).toBeLessThan(names.indexOf('wellKnownFallback'));
});
});
describe('PlatformModule', () => {
it('DISC-040: applies the consent COOP middleware to the /oauth/consent prefix', () => {
const forRoutes = vi.fn();
const consumer = { apply: vi.fn(() => ({ forRoutes })) };
new PlatformModule().configure(consumer as never);
expect(consumer.apply).toHaveBeenCalledWith(ConsentCoopMiddleware);
expect(forRoutes).toHaveBeenCalledWith('oauth/consent');
});
});
@@ -70,4 +70,17 @@ describe('FeaturesController (GET /api/health/features)', () => {
expect(Reflect.getMetadata('path', FeaturesController)).toBe('api/health');
expect(Reflect.getMetadata('path', FeaturesController.prototype.features)).toBe('features');
});
it('FEAT-009: GET /api/health writes the exact probe bytes (legacy parity)', () => {
const { controller } = make(true, true);
const res = {
headers: {} as Record<string, string>,
body: undefined as unknown,
setHeader: vi.fn(function (this: { headers: Record<string, string> }, k: string, v: string) { this.headers[k] = v; }),
json: vi.fn(function (this: { body: unknown }, b: unknown) { this.body = b; }),
};
controller.health(res as never);
expect(res.headers['Cache-Control']).toBe('no-store, must-revalidate');
expect(res.body).toEqual({ status: 'ok' });
});
});
@@ -5,12 +5,10 @@ import { NotFoundException } from '@nestjs/common';
const h = vi.hoisted(() => ({
verifyJwtAndLoadUser: vi.fn(),
isAddonEnabled: vi.fn(),
getMcpSafeUrl: vi.fn(() => 'https://trek.example.test'),
dbPrepare: vi.fn(),
existsSync: vi.fn(),
// SDK middleware spies — each returns a tagged handler so we can identify which
// app.use call received it.
metaRouter: vi.fn(),
authorizeHandler: vi.fn(),
registerHandler: vi.fn(),
mcpHandler: vi.fn(),
@@ -21,16 +19,9 @@ vi.mock('../../../src/db/database', () => ({ db: { prepare: h.dbPrepare } }));
vi.mock('../../../src/mcp', () => ({ mcpHandler: h.mcpHandler }));
vi.mock('../../../src/mcp/oauthProvider', () => ({ trekOAuthProvider: {}, trekClientsStore: {} }));
vi.mock('../../../src/nest/addons/addons.bridge', () => ({ isAddonEnabled: h.isAddonEnabled }));
vi.mock('../../../src/app-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/app-config')>();
return { ...actual, getMcpSafeUrl: h.getMcpSafeUrl };
});
// SDK router/handler factories return distinct tagged middleware so we never hit
// real new URL(...) wiring during registration.
vi.mock('@modelcontextprotocol/sdk/server/auth/router', () => ({
mcpAuthMetadataRouter: vi.fn(() => h.metaRouter),
}));
// SDK handler factories return distinct tagged middleware so we never hit real
// wiring during registration.
vi.mock('@modelcontextprotocol/sdk/server/auth/handlers/authorize', () => ({
authorizationHandler: vi.fn(() => h.authorizeHandler),
}));
@@ -101,7 +92,6 @@ function makeRes() {
beforeEach(() => {
vi.clearAllMocks();
h.getMcpSafeUrl.mockReturnValue('https://trek.example.test');
});
describe('applyPlatformUploads', () => {
@@ -234,91 +224,6 @@ describe('applyPlatformTransport', () => {
return calls;
}
it('GET /api/health sets no-store and returns ok', () => {
const calls = build();
const health = calls.find((c) => c.method === 'get' && c.path === '/api/health')!.handlers[0];
const res = makeRes();
health({}, res);
expect(res.headers['Cache-Control']).toBe('no-store, must-revalidate');
expect(res.body).toEqual({ status: 'ok' });
});
describe('the /.well-known metadata middleware', () => {
function wellKnownMw(calls: ReturnType<typeof build>) {
// first app.use with no path, registered right after /api/health
return calls.find((c) => c.method === 'use' && c.path === undefined)!.handlers[0];
}
it('404s a /.well-known path when MCP is disabled', () => {
h.isAddonEnabled.mockReturnValue(false);
const mw = wellKnownMw(build());
const res = makeRes();
const next = vi.fn();
mw({ path: '/.well-known/oauth-authorization-server' }, res, next);
expect(res.statusCode).toBe(404);
expect(next).not.toHaveBeenCalled();
});
it('delegates to the SDK meta router for a non-well-known path', () => {
h.isAddonEnabled.mockReturnValue(true);
const mw = wellKnownMw(build());
const res = makeRes();
const next = vi.fn();
mw({ path: '/anything' }, res, next);
expect(h.metaRouter).toHaveBeenCalled();
});
it('delegates to the SDK meta router for a well-known path when MCP is enabled', () => {
h.isAddonEnabled.mockReturnValue(true);
const mw = wellKnownMw(build());
const res = makeRes();
const next = vi.fn();
mw({ path: '/.well-known/oauth-authorization-server' }, res, next);
expect(h.metaRouter).toHaveBeenCalled();
});
});
it('GET /.well-known/openid-configuration returns AS metadata + userinfo_endpoint', () => {
const calls = build();
const handler = calls.find((c) => c.path === '/.well-known/openid-configuration')!.handlers[0];
const res = makeRes();
handler({}, res);
const body = res.body as { issuer: string; userinfo_endpoint: string };
expect(body.issuer).toBe('https://trek.example.test');
expect(body.userinfo_endpoint).toBe('https://trek.example.test/oauth/userinfo');
});
it('trims trailing slashes off the configured base URL', () => {
h.getMcpSafeUrl.mockReturnValue('https://trek.example.test///');
const calls = build();
const handler = calls.find((c) => c.path === '/.well-known/openid-configuration')!.handlers[0];
const res = makeRes();
handler({}, res);
expect((res.body as { issuer: string }).issuer).toBe('https://trek.example.test');
});
describe('GET /.well-known/oauth-protected-resource (flat)', () => {
function handler() {
return build().find((c) => c.method === 'get' && c.path === '/.well-known/oauth-protected-resource')!.handlers[0];
}
it('404 when MCP is disabled', () => {
h.isAddonEnabled.mockReturnValue(false);
const res = makeRes();
handler()({}, res);
expect(res.statusCode).toBe(404);
});
it('returns the PRM document when MCP is enabled', () => {
h.isAddonEnabled.mockReturnValue(true);
const res = makeRes();
handler()({}, res);
const body = res.body as { resource: string; authorization_servers: string[] };
expect(body.resource).toBe('https://trek.example.test/mcp');
expect(body.authorization_servers).toEqual(['https://trek.example.test']);
});
});
describe('mcpAddonGate (used on /oauth/authorize + /oauth/register)', () => {
function gate() {
// The gate is the first handler on the /oauth/authorize use registration.
@@ -357,60 +262,6 @@ describe('applyPlatformTransport', () => {
expect(calls.find((c) => c.method === 'get' && c.path === '/mcp')!.handlers[0]).toBe(h.mcpHandler);
expect(calls.find((c) => c.method === 'delete' && c.path === '/mcp')!.handlers[0]).toBe(h.mcpHandler);
});
describe('the terminal /.well-known JSON-404 middleware', () => {
function mw() {
// The pathless app.use registered after the /mcp routes.
const calls = build();
const pathless = calls.filter((c) => c.method === 'use' && c.path === undefined);
// first pathless = meta router; second = the JSON 404.
return pathless[1].handlers[0];
}
it('404 JSON for an unhandled /.well-known path', () => {
const res = makeRes();
const next = vi.fn();
mw()({ path: '/.well-known/unknown' }, res, next);
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'not_found' });
expect(next).not.toHaveBeenCalled();
});
it('calls next() for any non-well-known path', () => {
const res = makeRes();
const next = vi.fn();
mw()({ path: '/dashboard' }, res, next);
expect(next).toHaveBeenCalled();
});
});
it('the /oauth/consent middleware relaxes COOP then continues', () => {
const calls = build();
const mw = calls.find((c) => c.method === 'use' && c.path === '/oauth/consent')!.handlers[0];
const res = makeRes();
const next = vi.fn();
mw({}, res, next);
expect(res.headers['Cross-Origin-Opener-Policy']).toBe('unsafe-none');
expect(next).toHaveBeenCalled();
});
it('caches the OAuth metadata + SDK router across requests (lazy init runs once)', async () => {
const router = await import('@modelcontextprotocol/sdk/server/auth/router');
const calls = build();
const openid = calls.find((c) => c.path === '/.well-known/openid-configuration')!.handlers[0];
h.getMcpSafeUrl.mockClear();
openid({}, makeRes());
openid({}, makeRes());
// getMcpSafeUrl is only consulted on the first lazy build of the metadata.
expect(h.getMcpSafeUrl).toHaveBeenCalledTimes(1);
// Trigger the meta router lazy build twice; the SDK factory runs once.
const metaMw = calls.find((c) => c.method === 'use' && c.path === undefined)!.handlers[0];
h.isAddonEnabled.mockReturnValue(true);
metaMw({ path: '/x' }, makeRes(), vi.fn());
metaMw({ path: '/y' }, makeRes(), vi.fn());
expect(router.mcpAuthMetadataRouter).toHaveBeenCalledTimes(1);
});
});
describe('applyPlatformStatic', () => {