Files
TREK/server/src/nest/transit/transit.controller.ts
T
Maurice e606ebd9b9 chore(server): pin the WebSocket envelope, dedupe the rate limiter (#1803)
* chore(nest): register LlmParseModule in AppModule

The module was only in the container because BookingImportModule and
PluginsModule happen to import it, so /api/admin/llm/local depended on those two
keeping that import. Register it where every other module is registered.

* refactor(nest): give the rate limiter one home and one instance

RateLimitService keeps its counters in a Map on the instance, but it was listed
in the providers of auth, oauth, transit and trip-invite, so each of them got its
own. Nothing is broken today — the bucket names in use (login/mfa/forgot/reset,
oauth_*, transit_*, trip_invite) don't overlap — but two modules picking the same
name would have quietly allowed twice the configured attempts.

Move it to nest/common, where it belongs now that four domains use it, and hand
it out through a small module they import. Not @Global: the e2e suites build a
container around one domain module, so a global AppModule never pulled in isn't
there at all.

transit.mcp.ts keeps its own instance on purpose — it keys by userId under
mcp_transit_* while the REST path keys by IP.

* test(ws): pin the wire envelope before touching the transport

Every frame on the socket is flat: {type, tripId, ...payload} out, {type, tripId}
in. Nest's WsAdapter dispatches on message.event and answers {event, data}, so
moving this onto a gateway without a custom serialiser would leave join silently
unhandled — no welcome.socketId to send back as X-Socket-Id, and every client
would then receive its own writes.

Nothing catches that today: the 100+ tests that touch realtime mock src/websocket
and never look at the bytes. WS-032 sends the exact shape the stock adapter would
and asserts the server ignores it.

* docs(nest): drop the parts of the README that no longer exist

strangler.ts, DEFAULT_NEST_PREFIXES/NEST_PREFIXES and tests/parity/ are all gone —
every request goes through Nest now — but the README still walks a new contributor
through adding a prefix and writing a parity test against the Express route.

Also spell out that the 80% gate is an average over src/nest/**, not a floor per
file, so a large untested module can sit behind the well-covered small ones.
2026-08-06 22:44:59 +02:00

71 lines
2.4 KiB
TypeScript

import { Controller, Get, HttpException, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RateLimitService } from '../common/rate-limit.service';
import { TransitService } from './transit.service';
const RL_WINDOW = 15 * 60 * 1000;
/**
* /api/transit — public transit routing (#1065) proxied through Transitous
* (or a self-hosted MOTIS via TRANSIT_API_URL). JWT-guarded and rate-limited:
* the Transitous usage policy asks integrators to keep expensive routing
* traffic reasonable, so planning gets a tighter bucket than geocoding.
*/
@Controller('api/transit')
@UseGuards(JwtAuthGuard)
export class TransitController {
constructor(
private readonly rl: RateLimitService,
private readonly transit: TransitService,
) {}
private limit(bucket: string, req: Request, max: number): void {
if (!this.rl.check(bucket, req.ip || 'unknown', max, RL_WINDOW, Date.now())) {
throw new HttpException({ error: 'Too many requests. Please try again later.' }, 429);
}
}
private rethrow(err: unknown): never {
const status = (err as { status?: number }).status || 502;
const message = err instanceof Error ? err.message : 'Transit provider error';
throw new HttpException({ error: message }, status);
}
@Get('geocode')
async geocode(
@Query('q') q: string | undefined,
@Query('lang') lang: string | undefined,
@Query('near') near: string | undefined,
@Req() req: Request,
) {
this.limit('transit_geocode', req, 300);
try {
return await this.transit.geocode(q || '', lang, near);
} catch (err) { this.rethrow(err); }
}
@Get('plan')
async plan(
@Query('from') from: string | undefined,
@Query('to') to: string | undefined,
@Query('time') time: string | undefined,
@Query('arriveBy') arriveBy: string | undefined,
@Query('modes') modes: string | undefined,
@Query('maxTransfers') maxTransfers: string | undefined,
@Req() req: Request,
) {
this.limit('transit_plan', req, 60);
try {
return await this.transit.plan({
from: from || '',
to: to || '',
time,
arriveBy: arriveBy === 'true' || arriveBy === '1',
modes,
maxTransfers: maxTransfers !== undefined && maxTransfers !== '' ? Number(maxTransfers) : undefined,
});
} catch (err) { this.rethrow(err); }
}
}