refactor(server): inject the transit base URL and the kitinerary paths as config tokens

The ownership rule says a boot-stable value that only Nest providers read
belongs to a registerAs token, yet TransitService froze TRANSIT_API_URL in a
module-top readEnv() const and KitineraryExtractorService read
KITINERARY_EXTRACTOR_PATH and PATH through readEnv() in its probe. Both now
come from tokens: transitConfig and kitineraryConfig, derived in
boot-derive.ts and dropped from deriveIntegrations, so readEnv() no longer
reads those variables at all. TransitModule and KitineraryExtractorModule
import AppConfigModule, which keeps them buildable on their own in tests.

Behaviour is unchanged: the same defaults, the same trailing-slash strip and
PATH split, now frozen per built app instead of at import. The app-config
README names each boot-stable variable that stays on readEnv() together with
the reader the container cannot inject into.
This commit is contained in:
Maurice
2026-10-08 17:11:38 +02:00
parent a6f635f3e0
commit aa8d9b4037
15 changed files with 159 additions and 67 deletions
+25 -6
View File
@@ -101,12 +101,31 @@ Every variable has exactly one owner, and the owner decides how it is read:
`boot-derive.ts`): boot-stable values that only Nest classes need. The token
is snapshotted per built app and injected (`@Inject(storageConfig.KEY)`, or
`app.get(httpConfig.KEY)` for the pre-init Express layer). Today:
`httpConfig` (TRUST_PROXY, HSTS_INCLUDE_SUBDOMAINS, HTTP_KEEP_ALIVE_TIMEOUT_MS) and `storageConfig`
(TREK_PLACE_PHOTO_DIR).
`httpConfig` (TRUST_PROXY, HSTS_INCLUDE_SUBDOMAINS, HTTP_KEEP_ALIVE_TIMEOUT_MS),
`storageConfig` (TREK_PLACE_PHOTO_DIR), `transitConfig` (TRANSIT_API_URL,
for TransitService) and `kitineraryConfig` (KITINERARY_EXTRACTOR_PATH and
PATH, for KitineraryExtractorService's binary probe). A module whose provider
injects one imports `AppConfigModule`, so it also builds on its own in a test.
- **`readEnv()` / `RuntimeEnvService`** (`derive.ts`): everything else, both
the runtime-toggled values and the boot-stable ones that code outside the
container needs (config.ts, the database, src/mcp, the SSRF guard), which
freeze them in module-top consts.
the runtime-toggled values and the boot-stable ones that code outside a Nest
provider reads, which freezes them in module-top consts. Each of those has a
reader the container cannot inject into:
- PORT, HOST: `index.ts`, before the app exists.
- SESSION_DURATION(_REMEMBER), DEFAULT_LANGUAGE, ENCRYPTION_KEY: `src/config.ts`,
a plain module evaluated at process start.
- MCP_*: `src/mcp/`, the process-wide session state.
- TREK_PLUGIN_RPC_*/LOG_*/MAX_RSS_MB: the plugin host and supervisor, which
are deliberately not Nest (the sandbox boundary).
- TREK_PLUGIN_REGISTRY_URL: `PluginRegistryService` is a provider, but several
plugin suites build it by hand with a trailing `@Optional()` UnitOfWork, so
a required token parameter cannot go in without reordering that signature.
- TREK_WIKI_DIR: `nest/help/wiki.ts`, a plain module the help MCP tools call.
- BACKUP_*: `nest/backup/backup-archive.ts`, which the first-start restore
(`boot-restore.ts`, called from `index.ts`) runs before the database opens.
- LOG_LEVEL: the logger, imported everywhere.
- ALLOW_INTERNAL_NETWORK, ALLOW_LINK_LOCAL_IPS: the SSRF guard in `utils/`.
- TREK_DB_FILE, TREK_DB_JOURNAL_MODE, TREK_DB_SYNCHRONOUS: the database,
opened before the container.
`deriveAll()` never reads a variable a token owns, and a token is dropped once
nothing injects it. `tests/unit/app-config/config-ownership.test.ts` records
@@ -129,7 +148,7 @@ variable is token-owned, module-top `readEnv()` consts elsewhere):
PORT, HOST, TRUST_PROXY, HSTS_INCLUDE_SUBDOMAINS, HTTP_KEEP_ALIVE_TIMEOUT_MS, SESSION_DURATION(_REMEMBER), MCP_SESSION_TTL,
MCP_MAX_SESSION_PER_USER, MCP_SSE_KEEPALIVE, TREK_PLUGIN_RPC_*/LOG_*/MAX_RSS_MB,
TREK_PLUGIN_REGISTRY_URL, TREK_WIKI_DIR*, TREK_PLACE_PHOTO_DIR, BACKUP_*,
TRANSIT_API_URL, LOG_LEVEL*, ALLOW_INTERNAL_NETWORK*, ALLOW_LINK_LOCAL_IPS*, DEFAULT_LANGUAGE,
TRANSIT_API_URL, KITINERARY_EXTRACTOR_PATH, LOG_LEVEL*, ALLOW_INTERNAL_NETWORK*, ALLOW_LINK_LOCAL_IPS*, DEFAULT_LANGUAGE,
TREK_DB_FILE, TREK_DB_JOURNAL_MODE, TREK_DB_SYNCHRONOUS, ENCRYPTION_KEY**.
(* frozen today because the consuming module captures it at import; tests that
override these set them at file top, before the SUT import.)
+23 -1
View File
@@ -9,7 +9,7 @@
* mutate mid-lifetime, belongs in derive.ts instead.
*/
import type { RawEnv } from './derive';
import { parseBool, positiveIntOr } from './parsers';
import { parseBool, positiveIntOr, stripTrailingSlashes } from './parsers';
/**
* Longer than the upstream idle timeout of the common reverse proxies and load
@@ -42,10 +42,32 @@ export function deriveStorage(raw: RawEnv) {
};
}
/** The Transitous/MOTIS instance TransitService proxies to. */
export function deriveTransit(raw: RawEnv) {
return {
apiBase: stripTrailingSlashes(raw.TRANSIT_API_URL || 'https://api.transitous.org'),
};
}
/** Where KitineraryExtractorService looks for its binary when the module starts. */
export function deriveKitinerary(raw: RawEnv) {
return {
extractorPath: raw.KITINERARY_EXTRACTOR_PATH,
// Windows spells it Path; every other platform PATH. Split here so the
// probe gets a list and never re-implements the delimiter.
searchPath: (raw.PATH || raw.Path || '')
.split(process.platform === 'win32' ? ';' : ':')
.map((p) => p.trim())
.filter(Boolean),
};
}
/** One entry per token: the namespace it registers under and the function it derives with. */
export const BOOT_DERIVERS = {
http: deriveHttpBoot,
storage: deriveStorage,
transit: deriveTransit,
kitinerary: deriveKitinerary,
} as const;
export type BootNamespace = keyof typeof BOOT_DERIVERS;
+3 -8
View File
@@ -279,7 +279,6 @@ export function deriveWebauthn(raw: RawEnv) {
export function deriveIntegrations(raw: RawEnv) {
return {
unsplashAccessKey: raw.UNSPLASH_ACCESS_KEY?.trim(),
transitApiBase: stripTrailingSlashes(raw.TRANSIT_API_URL || 'https://api.transitous.org'),
// Trimmed before the default fires: the schema validates the trimmed value and
// treats a blank one as unset, so a padded or whitespace-only value would
// otherwise pass startup and then be the string that cannot be fetched.
@@ -288,7 +287,6 @@ export function deriveIntegrations(raw: RawEnv) {
// Longer than the `[timeout:20]` the query itself carries, or we abort an answer the
// mirror was still allowed to be working on. See OVERPASS_QUERY_TIMEOUT_S.
overpassTimeoutMs: positiveNumberOr(raw.OVERPASS_TIMEOUT_MS, 25000),
kitineraryExtractorPath: raw.KITINERARY_EXTRACTOR_PATH,
/**
* One ceiling for a model call, replacing the three per-client constants
* that used to disagree. The default is deliberately generous: heavier
@@ -297,12 +295,9 @@ export function deriveIntegrations(raw: RawEnv) {
* rejects a fractional value.
*/
llmTimeoutMs: Math.floor(positiveNumberOr(raw.LLM_TIMEOUT_MS, 900_000)),
// Windows spells it Path; every other platform PATH. Split here so callers
// get a list and never re-implement the delimiter.
searchPath: (raw.PATH || raw.Path || '')
.split(process.platform === 'win32' ? ';' : ':')
.map(p => p.trim())
.filter(Boolean),
// TRANSIT_API_URL, KITINERARY_EXTRACTOR_PATH and PATH are boot-stable and
// read only by Nest providers, so the transitConfig and kitineraryConfig
// tokens own them (boot-derive.ts).
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
export { AppConfigModule } from './app-config.module';
export { RuntimeEnvService } from './runtime-env.service';
export { DataPathsService } from './data-paths.service';
export { httpConfig, storageConfig, BOOT_STABLE_TOKENS } from './tokens';
export { httpConfig, storageConfig, transitConfig, kitineraryConfig, BOOT_STABLE_TOKENS } from './tokens';
+5 -1
View File
@@ -27,5 +27,9 @@ import { BOOT_DERIVERS } from '../../app-config/boot-derive';
export const httpConfig = registerAs('http', () => BOOT_DERIVERS.http(process.env));
/** The storage registry's conditional place-photo backend (TREK_PLACE_PHOTO_DIR). */
export const storageConfig = registerAs('storage', () => BOOT_DERIVERS.storage(process.env));
/** TransitService's upstream (TRANSIT_API_URL). */
export const transitConfig = registerAs('transit', () => BOOT_DERIVERS.transit(process.env));
/** KitineraryExtractorService's binary probe (KITINERARY_EXTRACTOR_PATH, then PATH). */
export const kitineraryConfig = registerAs('kitinerary', () => BOOT_DERIVERS.kitinerary(process.env));
export const BOOT_STABLE_TOKENS = [httpConfig, storageConfig];
export const BOOT_STABLE_TOKENS = [httpConfig, storageConfig, transitConfig, kitineraryConfig];
@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';
import { AppConfigModule } from '../app-config/app-config.module';
import { KitineraryExtractorService } from './kitinerary-extractor.service';
/** The kitinerary binary probe on its own. It has no dependencies, and the
* feature-flag endpoint in health/ needs nothing else from booking-import —
* importing the whole domain there would drag llm-parse, reservations, budget,
* maps and places along for one `isAvailable()`. */
/** The kitinerary binary probe on its own. Its only dependency is the
* kitineraryConfig token (AppConfigModule), and the feature-flag endpoint in
* health/ needs nothing else from booking-import — importing the whole domain
* there would drag llm-parse, reservations, budget, maps and places along for
* one `isAvailable()`. */
@Module({
imports: [AppConfigModule],
providers: [KitineraryExtractorService],
exports: [KitineraryExtractorService],
})
@@ -1,10 +1,11 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { execFile } from 'node:child_process';
import { existsSync, readdirSync, writeFileSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, extname } from 'node:path';
import { randomUUID } from 'node:crypto';
import { readEnv } from '../../app-config';
import { kitineraryConfig } from '../app-config/tokens';
import { logDebug } from '../audit/audit-log.logger';
import { execFileSync } from 'node:child_process';
import { promisify } from 'node:util';
@@ -29,8 +30,11 @@ export class KitineraryExtractorService implements OnModuleInit {
* one failure that otherwise looks exactly like "not installed". */
private configuredPath: string | null = null;
/** KITINERARY_EXTRACTOR_PATH and PATH, frozen per built app like the probe result itself. */
constructor(@Inject(kitineraryConfig.KEY) private readonly kitineraryEnv: ConfigType<typeof kitineraryConfig>) {}
onModuleInit() {
this.configuredPath = readEnv().integrations.kitineraryExtractorPath || null;
this.configuredPath = this.kitineraryEnv.extractorPath || null;
this.binaryPath = this.findBinary();
if (this.binaryPath) {
this.binaryVersion = this.probeVersion(this.binaryPath);
@@ -131,7 +135,7 @@ export class KitineraryExtractorService implements OnModuleInit {
}
private findBinary(): string | null {
const envPath = readEnv().integrations.kitineraryExtractorPath;
const envPath = this.kitineraryEnv.extractorPath;
if (envPath) {
if (existsSync(envPath)) return envPath;
console.warn(`[KItinerary] KITINERARY_EXTRACTOR_PATH="${envPath}" not found`);
@@ -153,7 +157,7 @@ export class KitineraryExtractorService implements OnModuleInit {
// this branch is dead there) anyone who could write to a PATH directory could
// have their binary run as the TREK user. Probing the concrete file with
// execFileSync also drops the /bin/sh hop the old execSync string needed.
for (const dir of readEnv().integrations.searchPath) {
for (const dir of this.kitineraryEnv.searchPath) {
const candidate = join(dir, BINARY_NAME);
if (!existsSync(candidate)) continue;
try {
+4 -1
View File
@@ -13,12 +13,15 @@ import { AppSettings } from '../../db/entities/AppSettings.entity';
import { Users } from '../../db/entities/Users.entity';
import { Trips } from '../../db/entities/Trips.entity';
import { GoogleQuotaModule } from '../google-quota/google-quota.module';
import { AppConfigModule } from '../app-config/app-config.module';
/**
* Transit domain (#1065) — the Transitous/MOTIS proxy, with the optional
* Google backend (#1699) behind it. TransitMcp carries the
* decorator-registered MCP tools; DaysModule/ReservationsModule feed
* create_transit_journey. Exports TransitService for in-container consumers.
* AppConfigModule provides the transitConfig token (TRANSIT_API_URL) that
* TransitService injects.
*
* MikroOrmModule.forFeature([AppSettings, Users, Trips]): GoogleTransitProvider
* passes its own AppSettingsRepository/UsersRepository to
@@ -30,7 +33,7 @@ import { GoogleQuotaModule } from '../google-quota/google-quota.module';
*/
@Module({
// DaysModule + ReservationsModule: TransitMcp's create_transit_journey injects both.
imports: [McpSharedModule, RateLimitModule, DaysModule, ReservationsModule, AuthModule, MikroOrmModule.forFeature([AppSettings, Users, Trips]), GoogleQuotaModule],
imports: [AppConfigModule, McpSharedModule, RateLimitModule, DaysModule, ReservationsModule, AuthModule, MikroOrmModule.forFeature([AppSettings, Users, Trips]), GoogleQuotaModule],
controllers: [TransitController],
providers: [TransitService, TransitMcp, GoogleTransitProvider],
exports: [TransitService],
+17 -14
View File
@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import { getAppUrl, readEnv } from '../../app-config';
import { Inject, Injectable } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { getAppUrl } from '../../app-config';
import { transitConfig } from '../app-config/tokens';
import { buildUserAgent } from '../maps/maps.helpers';
import { GoogleTransitProvider } from './google-transit.provider';
import type { TransitProvider } from '@trek/shared';
@@ -37,15 +39,13 @@ import {
* contact info, which we send once from the server), and responses are mapped
* to a compact shape so the client isn't coupled to the MOTIS schema.
*
* Folded 1:1 from the legacy services/transitService.ts. The base URL, the
* lazy User-Agent memo and the response cache stay module-scoped on purpose
* (the permissions/exchange-rates precedent): the DI singleton and any future
* non-Nest caller share one cache, and the base URL keeps its frozen-at-import
* legacy timing.
* Folded 1:1 from the legacy services/transitService.ts. The lazy User-Agent
* memo and the response cache stay module-scoped on purpose (the
* permissions/exchange-rates precedent): the DI singleton and any future
* non-Nest caller share one cache. The base URL (TRANSIT_API_URL) is
* boot-stable and comes from the transitConfig token, frozen per built app.
*/
// Frozen at import on purpose (legacy timing).
const TRANSIT_API_BASE = readEnv().integrations.transitApiBase;
let userAgent: string | null = null;
function getUserAgent(): string {
@@ -95,8 +95,8 @@ function isCoord(v: string): boolean {
// megabyte; anything bigger is a misbehaving provider, not data we want to map.
const MAX_RESPONSE_BYTES = 5_000_000;
async function upstream(path: string, params: URLSearchParams): Promise<unknown> {
const url = `${TRANSIT_API_BASE}${path}?${params}`;
async function upstream(base: string, path: string, params: URLSearchParams): Promise<unknown> {
const url = `${base}${path}?${params}`;
const res = await fetch(url, {
headers: { 'User-Agent': getUserAgent(), Accept: 'application/json' },
signal: AbortSignal.timeout(8000),
@@ -154,7 +154,10 @@ function mapStop(p: MotisPlaceRaw | undefined, kind: 'departure' | 'arrival'): T
@Injectable()
export class TransitService {
constructor(private readonly google: GoogleTransitProvider) {}
constructor(
private readonly google: GoogleTransitProvider,
@Inject(transitConfig.KEY) private readonly transitEnv: ConfigType<typeof transitConfig>,
) {}
/**
* Station/place search for the from/to pickers. `near` biases results.
@@ -192,7 +195,7 @@ export class TransitService {
const cached = cacheGet(key);
if (cached) return { ...(cached as { results: TransitPlace[] }), provider };
const raw = (await upstream('/api/v1/geocode', params)) as Array<{
const raw = (await upstream(this.transitEnv.apiBase, '/api/v1/geocode', params)) as Array<{
name?: string;
lat?: number;
lon?: number;
@@ -261,7 +264,7 @@ export class TransitService {
const cached = cacheGet(key);
if (cached) return { ...(cached as { itineraries: TransitItinerary[] }), provider: 'transitous' };
const raw = (await upstream('/api/v6/plan', params)) as {
const raw = (await upstream(this.transitEnv.apiBase, '/api/v6/plan', params)) as {
itineraries?: Array<{
duration?: number;
startTime?: string;