mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
77162aa8d5
The switch a road tripper keeps asking for has never worked. OSRM's `exclude` is the only
avoidance TREK has, and the two public hosts it ships with answer it HTTP 400 on every
leg: their car profile is built without excludable classes, and their own Lua says why
("no memory for classes on demo server"). So "drive this without the tolls" produced
nothing at all on a default install, and the fallback inside the alternatives bar quietly
gave up after one refusal per host.
FOSSGIS runs a public Valhalla on the whole planet next to that OSRM, keyless, and there
avoidance is a runtime weighting rather than a profile that has to be rebuilt. This adds
it as a second engine asked only the questions the first cannot answer, and puts three
switches under the road trip settings that change the road itself rather than offering an
alternative beside it. Paris to Lyon measured 465.8 km / 297 min with tolls against
492.0 km / 454 min without; Hamburg to Copenhagen comes back ferry-free at 469.8 km,
which OSRM cannot produce at all — where a ferry IS the crossing it answers NoRoute
rather than driving round.
Four things the docs would have got wrong, all measured against the live instance:
- `format=osrm` does not carry `has_toll`. That flag is the entire reason for doing this,
and it exists only in the native answer — which is also 5.7x smaller, because the OSRM
shape drags every intersection along. So the adapter translates instead of borrowing a
familiar envelope.
- Without a `radius` Valhalla refuses a point it cannot bind to a road, where OSRM still
snaps one. Vienna's Stephansdom, in a pedestrian zone, answers error 442 until a search
radius is allowed. TREK is full of such places, so every location carries one.
- Ten locations is a hard cap and a road trip day is bundled into one request. A day over
it is split into runs overlapping by one point, which is exact rather than approximate:
a measured six-stop day came back within two metres and under a second of the same day
asked whole.
- Its drive times run 5.4 % above OSRM's over ten European legs and 36 % above on one, and
those times feed the arrival clocks of a day. So a day is answered by one engine or by
neither, never by both, and a piece that will not answer loses the whole day rather than
half of it — the schedule chains leg times blindly and would otherwise print a complete,
plausible, silently wrong timetable.
Because the weighting can fail, the honest surface is the answer rather than the request.
`use_tolls: 0` still returns a tolled road where there is no untolled crossing, so what
the road actually turned out to be is read back per leg, and a day that could not avoid
what it was asked to avoid says so on its own header instead of wearing a label the road
disproves. The switches disable themselves, with a reason, on an instance pointed at its
own OSRM: that operator chose against public hosts, and a second one they never asked for
would undo the choice.
Two things that would have broken quietly and did not:
- The route cache keys on `profile:unit:coords` and is shared with the day planner, the
studio and the booking geometry. Without the avoidance in the key, flipping a switch
returns the old road out of cache in under a millisecond — and worse, an avoiding answer
stored under the plain key hands those other callers a road nobody drives.
- `snapped` is OSRM-only and carries the dashed kerb-to-door spur from #2273. Valhalla
echoes the coordinates it was given and never says where it bound them, but the ends of
a leg's line are exactly those points: measured against Füssen, its first vertex matches
OSRM's snapped location to the digit.
The alternatives bar gets the same engine, which finally makes its "no motorway" and "no
tolls" offers appear on a default install, plus a ferry-free one. `sameRoad` now compares
geometry rather than numbers there, because two engines' distances for the same road sit
kilometres apart and the old 200 m tolerance called that a discovery.
Desktop only, like the whole road trip mode: nothing under client/src/mobile renders it.
179 lines
10 KiB
TypeScript
179 lines
10 KiB
TypeScript
import nodeHttp from 'node:http';
|
|
import express from 'express';
|
|
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
|
import { NestFactory } from '@nestjs/core';
|
|
import { ExpressAdapter } from '@nestjs/platform-express';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
import type { ConfigType } from '@nestjs/config';
|
|
import { AppModule } from './nest/app.module';
|
|
import { httpConfig } from './nest/app-config';
|
|
import { applyGlobalMiddleware, routingCspOrigins } from './middleware/globalMiddleware';
|
|
import { SettingsService } from './nest/settings/settings.service';
|
|
import { applyPlatformUploads, applyPlatformStatic } from './nest/platform/platform.routes';
|
|
import { apiDocsEnabled } from './nest/common/api-docs.kill-switch';
|
|
import { setupApiDocs } from './nest/platform/api-docs';
|
|
import { MCP_METADATA_MIDDLEWARE } from './nest/platform/mcp-metadata.middleware';
|
|
import { validateBodyContracts } from './nest/common/validate-body-contracts';
|
|
import { validateRouteGuards } from './nest/common/validate-route-guards';
|
|
import { validateManagedRoutes } from './nest/common/validate-managed-routes';
|
|
import { TrekWsAdapter } from './nest/realtime/trek-ws.adapter';
|
|
import { StorageService } from './nest/storage/storage.service';
|
|
|
|
/**
|
|
* Builds the unified TREK NestJS application that serves the ENTIRE surface — the
|
|
* former Express app is gone. One builder is shared by the production bootstrap
|
|
* (index.ts) and the integration-test harness so the two can never drift.
|
|
*
|
|
* Composition order is load-bearing. The few remaining pre-`app.init()` pieces
|
|
* are registered on the underlying Express instance first, because Nest's
|
|
* router terminates an unmatched request by throwing NotFoundException — it
|
|
* does NOT fall through to a route registered after init, so a post-init
|
|
* Express route is unreachable. Each pre-init piece matches its own requests
|
|
* and `next()`s everything else through to the Nest controllers:
|
|
*
|
|
* 1. applyGlobalMiddleware — helmet/CSP, CORS, HSTS, forced-HTTPS, request
|
|
* logging + cookie-parser.
|
|
* 2. applyPlatformUploads — the static + guarded /uploads/* routes.
|
|
* 3. MCP_METADATA_MIDDLEWARE — the SDK's OAuth discovery router + addon
|
|
* gate, container-built (DI) but mounted pathless here (see below).
|
|
* 4. applyPlatformStatic — the production built-client static assets (so a
|
|
* real asset request returns the file before the Nest router 404s it).
|
|
* 4b. setupApiDocs — Swagger UI/spec at /api/docs* when TREK_API_DOCS_ENABLED.
|
|
* 4c. the named body-parser wrappers — the global 100kb JSON/urlencoded
|
|
* parsers, exempting /mcp so the MCP SDK reads the raw stream.
|
|
* 5. app.init() — every domain controller, including the transport surface
|
|
* that used to live pre-init: /api/health (FeaturesController), OAuth
|
|
* discovery documents (DiscoveryController), the /oauth/consent COOP
|
|
* override (ConsentCoopMiddleware), /oauth/authorize + /oauth/register
|
|
* (SDK routers via OauthModule.configure) and /mcp (McpTransportModule).
|
|
*
|
|
* The SPA index.html fallback (unmatched GET → index.html in production) is the
|
|
* SpaFallbackFilter (APP_FILTER in AppModule); the global error envelope is the
|
|
* TrekExceptionFilter (also APP_FILTER).
|
|
*/
|
|
let boundHttpServer: nodeHttp.Server | null = null;
|
|
|
|
/**
|
|
* The http server buildApp created and bound the ws gateway to.
|
|
*
|
|
* index.ts listens on it; the websocket suites connect to it. Anyone creating a
|
|
* second server around the same express instance would get a working REST API
|
|
* and a /ws that no client can reach.
|
|
*/
|
|
export function getHttpServer(): nodeHttp.Server {
|
|
if (!boundHttpServer) throw new Error('buildApp() has not run yet');
|
|
return boundHttpServer;
|
|
}
|
|
|
|
export async function buildApp(): Promise<INestApplication> {
|
|
// rawBody keeps the unparsed request bytes on req.rawBody so a plugin webhook
|
|
// route can verify a provider's HMAC signature over the exact payload (the
|
|
// parsed JSON alone can't be re-serialised byte-for-byte).
|
|
const app = await NestFactory.create(AppModule, new ExpressAdapter(), { rawBody: true });
|
|
const instance = app.getHttpAdapter().getInstance();
|
|
// The http server is created HERE, not by the caller after buildApp returns,
|
|
// and that ordering is the whole point: Nest binds gateways during app.init(),
|
|
// so the ws adapter has to exist before it and has to already know which
|
|
// server to attach to. Handing it the app instead would bind the ws server to
|
|
// Nest's own internal http server, which this process never listens on: the
|
|
// boot succeeds, the gateway logs as registered, every test passes, and no
|
|
// browser can connect. Callers take the server from getHttpServer() below.
|
|
boundHttpServer = nodeHttp.createServer(instance);
|
|
app.useWebSocketAdapter(new TrekWsAdapter(boundHttpServer));
|
|
// ConfigModule.forRoot's load factories already ran inside NestFactory.create,
|
|
// so the boot-stable snapshot is resolvable here, BEFORE app.init() — this is
|
|
// the one bridge that lets the pre-init Express layer consume the validated
|
|
// config instead of reading process.env itself.
|
|
const http = app.get<ConfigType<typeof httpConfig>>(httpConfig.KEY);
|
|
// Same pre-init bridge: a self-hosted routing engine has to be named in connect-src, or
|
|
// the browser blocks every request to it without an error the app could report. Both
|
|
// engines go through the same door — the second one answers the avoidance questions
|
|
// the first cannot, and is blocked just as silently when the policy leaves it out.
|
|
const settings = app.get(SettingsService, { strict: false });
|
|
const defaults = settings?.getAdminUserDefaults();
|
|
const asUrl = (value: unknown) => (typeof value === 'string' ? value : null);
|
|
applyGlobalMiddleware(instance, {
|
|
http,
|
|
extraConnectSrc: routingCspOrigins([
|
|
asUrl(defaults?.routing_base_url),
|
|
asUrl(defaults?.valhalla_base_url),
|
|
]),
|
|
});
|
|
// Same pre-init consumption bridge as httpConfig above: the StorageService
|
|
// instance is resolvable before init, and the handlers only *register* here —
|
|
// per-request resolution runs after app.init() completed the registry load.
|
|
applyPlatformUploads(instance, app.get(StorageService));
|
|
// 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
|
|
// left the limit implicit — the same number, but nowhere anybody would find
|
|
// it, and one NestFactory default away from silently becoming something else.
|
|
// The verify hook keeps req.rawBody, which the plugin webhook routes need to
|
|
// check a provider's HMAC over the exact payload (same hook Nest's own
|
|
// useBodyParser would install).
|
|
//
|
|
// /mcp is exempted and stays RAW: the MCP SDK's StreamableHTTPServerTransport
|
|
// reads the request stream itself (its own size handling and JSON-RPC error
|
|
// bodies), and the transport controller passes the still-undefined req.body
|
|
// through as handleRequest's parsedBody argument. No Nest route may ever
|
|
// @Body() on /mcp. The wrapper NAMES are load-bearing:
|
|
// ExpressAdapter.registerParserMiddleware skips its own init-time parsers
|
|
// only when layers named jsonParser/urlencodedParser already sit in the stack.
|
|
const rawBodyKeeper = (req: Request, _res: Response, buffer: Buffer) => {
|
|
if (Buffer.isBuffer(buffer)) (req as Request & { rawBody?: Buffer }).rawBody = buffer;
|
|
};
|
|
/*
|
|
* ── Why one route gets a larger body than the rest ───────────────────
|
|
*
|
|
* A hundred kilobytes is the right ceiling for an API of forms and ids, and
|
|
* it is the wrong one for a photo book. A Studio document is sent WHOLE on
|
|
* every autosave — deliberately, see book-store.schema.ts, because a patch
|
|
* protocol would need an ordering guarantee and a merge rule per field — and
|
|
* the contract already caps it at 150 spreads of 90 elements. A real book of
|
|
* a fortnight's journey is well past a hundred kilobytes before anybody asks
|
|
* for road geometry, and what the ceiling produces is not an error message
|
|
* but a save that quietly fails and an editor that says "not saved" without
|
|
* saying why.
|
|
*
|
|
* So the book route, and only the book route, is measured against the size a
|
|
* book can actually be. Everything else keeps the tighter limit.
|
|
*/
|
|
const bookBody = express.json({ limit: '8mb', verify: rawBodyKeeper });
|
|
const json = express.json({ limit: '100kb', verify: rawBodyKeeper });
|
|
const urlencoded = express.urlencoded({ limit: '100kb', extended: true, verify: rawBodyKeeper });
|
|
const isMcp = (req: Request) => req.path === '/mcp' || req.path === '/mcp/';
|
|
const isBookWrite = (req: Request) =>
|
|
req.method === 'PUT' && /^\/api\/journeys\/\d+\/book$/.test(req.path);
|
|
|
|
instance.use(function jsonParser(req: Request, res: Response, next: NextFunction) {
|
|
if (isBookWrite(req)) return bookBody(req, res, next);
|
|
return isMcp(req) ? next() : json(req, res, next);
|
|
});
|
|
instance.use(function urlencodedParser(req: Request, res: Response, next: NextFunction) {
|
|
return isMcp(req) ? next() : urlencoded(req, res, next);
|
|
});
|
|
if (apiDocsEnabled()) setupApiDocs(app);
|
|
await app.init();
|
|
// Fail closed on unvalidated mutation bodies: every POST/PUT/PATCH @Body()
|
|
// must carry a createZodDto class (validated by the global ZodValidationPipe)
|
|
// or sit on the ratchet-only legacy allow-list — otherwise refuse to boot.
|
|
validateBodyContracts(app);
|
|
// Fail closed on the anonymous surface too: a route that answers without a
|
|
// session must carry @Public() with a reason AND be on the reviewed list.
|
|
// Default-deny protects what exists; this is what keeps the exemptions honest.
|
|
validateRouteGuards(app);
|
|
// And on the other direction of the same question: a route that a centrally
|
|
// administered install withholds from its own admin must say why, and be on a
|
|
// list somebody reviewed. Runs in every e2e harness because they share this
|
|
// builder, so a forgotten marker fails in CI rather than at a customer.
|
|
validateManagedRoutes(app);
|
|
return app;
|
|
}
|