Files
TREK/server/src/app-config/derive.ts
T
Maurice 256f8c08e2 feat(server): ask the TREK services' Photon before Nominatim for search and reverse lookups
An installation that holds a key for the TREK services and has no
NOMINATIM_URL of its own now sends the OpenStreetMap search and the
reverse lookups to photon.liketrek.com first. The answers are read into
Nominatim's shape, so the place records stay the same, and every miss
(refused key, allowance spent, timeout, error, foreign body) falls back
to Nominatim, with a one minute pause after four misses in a row.

TREK_PHOTON_URL points at another copy, and the default of
TREK_SERVICES_HOSTS now lists photon.liketrek.com and
valhalla.liketrek.com beside places.liketrek.com.
2026-10-29 13:34:28 +01:00

412 lines
18 KiB
TypeScript

/**
* Pure per-namespace derive functions: (raw env) → typed config namespace.
*
* Every field pins the EXACT coercion of the call site(s) it replaces — parity
* is law — with ONE deliberate exception: boolean switches are unified through
* parseBool (true/1/on/yes vs false/0/off/no, any casing), because the legacy
* per-site literals ('true' here, '1' there, 'on' elsewhere) were an accident,
* not a contract. Where sites apply different DEFAULTS for the same variable
* (DEMO_ADMIN_EMAIL), only the raw value is exposed and the default stays at
* the call site.
*
* These functions are plain string coercions with NO caching and NO side
* effects: `readEnv()` (env.ts) re-derives from the live process.env on every
* call, which is what keeps the runtime env-mutation semantics the test suite
* depends on. Zod validation runs once at boot (env.schema.ts), never here.
*/
import { imageVersion } from './image-version';
import {
csvList,
csvListFiltered,
numberOr,
parseBool,
parseDurationMs,
parseLinkLocalAllowList,
positiveIntOr,
positiveNumberOr,
resolveDurability,
resolveKeepaliveMs,
resolveSessionTtlMs,
stripTrailingSlashes,
} from './parsers';
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared';
export type RawEnv = Record<string, string | undefined>;
export function deriveApp(raw: RawEnv) {
const nodeEnv = raw.NODE_ENV;
return {
/** Raw NODE_ENV — for the case-SENSITIVE sites (`=== 'production'` in globalMiddleware HSTS, platform statics, spa-fallback; `=== 'development'` in authService.dev_mode). */
nodeEnv,
isProduction: nodeEnv?.toLowerCase() === 'production',
isDevelopment: nodeEnv?.toLowerCase() === 'development',
/** Case-sensitive on purpose — db/database.ts picks :memory: only on exactly 'test'. */
isTest: nodeEnv === 'test',
port: numberOr(raw.PORT, 3001),
host: raw.HOST,
/**
* The image's own VERSION file, else raw APP_VERSION. Fallbacks differ per
* site ('0.0.0' vs package.json vs semver-validated); each keeps its own.
* The file wins because a container keeps its env across image updates
* (image-version.ts).
*/
appVersion: imageVersion() ?? raw.APP_VERSION,
/** Raw APP_URL — trailing-slash stripping differs per site (feeds strips one, notifications strips all). */
appUrl: raw.APP_URL,
tz: raw.TZ,
logLevel: raw.LOG_LEVEL,
/** Resolved: lowercased, validated against the supported set, falls back to 'en' (src/config.ts semantics). */
defaultLanguage: resolveDefaultLanguage(raw.DEFAULT_LANGUAGE),
};
}
function resolveDefaultLanguage(raw: string | undefined): string {
const lang = raw?.toLowerCase() || 'en';
// Case-insensitive match resolved to the CANONICAL code (zh-TW, not zh-tw).
// The legacy lowercase-only `includes` could never match zh-TW and silently
// fell back to English for a supported language — deliberate fix.
const canonical = SUPPORTED_LANGUAGE_CODES.find((c) => c.toLowerCase() === lang);
return canonical ?? 'en';
}
/**
* The live half of the HTTP settings. TRUST_PROXY and HSTS_INCLUDE_SUBDOMAINS
* are not here: they are boot-stable and owned by the `httpConfig` token
* (boot-derive.ts), so they cannot be read through two doors.
*/
export function deriveHttp(raw: RawEnv) {
return {
allowedOriginsRaw: raw.ALLOWED_ORIGINS,
/** globalMiddleware CORS variant: trim + drop empty entries. */
corsOrigins: csvListFiltered(raw.ALLOWED_ORIGINS),
/** websocket variant: trim only — an empty entry stays (and can never match an Origin header). */
wsOrigins: csvList(raw.ALLOWED_ORIGINS),
forceHttps: parseBool(raw.FORCE_HTTPS) === true,
/** COOKIE_SECURE is tri-state: an explicit falsy value disables secure cookies; anything else means auto-detect. */
cookieSecureDisabled: parseBool(raw.COOKIE_SECURE) === false,
apiDocsEnabled: parseBool(raw.TREK_API_DOCS_ENABLED) === true,
};
}
const DEFAULT_SESSION_DURATION = '24h';
const DEFAULT_SESSION_DURATION_REMEMBER = '30d';
const DEFAULT_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days (scheduler.ts)
export function deriveSession(raw: RawEnv) {
const durationRaw = raw.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION;
const parsedMs = parseDurationMs(durationRaw);
const rememberRaw = raw.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
const parsedRememberMs = parseDurationMs(rememberRaw);
const durationMs = parsedMs ?? parseDurationMs(DEFAULT_SESSION_DURATION)!;
const rememberMs = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
return {
/** Human-readable session length actually in effect (invalid input falls back, matching src/config.ts). */
duration: parsedMs == null ? DEFAULT_SESSION_DURATION : durationRaw,
durationMs,
durationSeconds: Math.floor(durationMs / 1000),
durationRemember: parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rememberRaw,
durationRememberMs: rememberMs,
durationRememberSeconds: Math.floor(rememberMs / 1000),
idempotencyTtlSeconds: positiveNumberOr(raw.IDEMPOTENCY_TTL_SECONDS, DEFAULT_IDEMPOTENCY_TTL_SECONDS),
};
}
/**
* Whether this instance is centrally administered: somebody other than its
* admin user owns the configuration, the credentials and the upgrade schedule.
*
* Only a boolean, on purpose. It answers "who configures this install", and the
* surfaces that care read it themselves rather than being listed here, so adding
* one never touches this file.
*
* `=== true` so an unset, empty or unparseable value means off. A managed
* install sets it deliberately; nothing should be able to switch it on by
* accident, and a typo must not silently take settings away from an admin.
*/
export function deriveManaged(raw: RawEnv) {
return {
enabled: parseBool(raw.TREK_MANAGED) === true,
};
}
/**
* Where the Places calls go and which credential they carry.
*
* Both unset on a self-hosted install, which is the path the code has always
* taken: the endpoints stay Google's and the key comes from the users table.
* Set them and the calls leave through something the operator runs, with a
* credential no instance admin can read back.
*/
export function deriveMaps(raw: RawEnv) {
return {
placesApiBase: raw.PLACES_API_BASE || undefined,
googleListDataBase: raw.GOOGLE_LIST_DATA_BASE?.trim() || undefined,
/**
* Base URL of the TREK Places API. Unset means the public instance; an
* operator who runs their own copy of the index points at it here, and one
* who wants nothing to leave their network points at their own machine.
*/
trekPlacesUrl: raw.TREK_PLACES_URL || undefined,
/**
* Whether the index answers at all. On unless an operator says otherwise,
* because it is the path we want people on and an upgrade must not quietly
* drop back to Nominatim, whose usage policy forbids what TREK was doing
* with it.
*
* Deliberately an environment variable and not an admin switch: it decides
* whether searches leave the instance, and a setting that reaches for the
* network is one an operator wants pinned in their compose file, not one a
* second admin can flip in a browser.
*
* Off only on a value of the false family the schema admits (false, 0,
* off, no), so the switch reads like every other boolean here. Unset,
* blank and anything else stay on: the schema refuses a value outside the
* family at boot, and a path that skips validation must not quietly drop
* the install back to Nominatim.
*/
trekPlacesEnabled: parseBool(raw.TREK_PLACES_ENABLED) !== false,
placesApiKey: raw.PLACES_API_KEY || undefined,
/** The same two knobs for Amap (高德), which an install in China uses instead. */
amapApiBase: raw.AMAP_API_BASE || undefined,
amapApiKey: raw.AMAP_API_KEY || undefined,
/**
* Amap's optional 数字签名 secret. A key created with one rejects every
* unsigned request, so this is not a hardening option — it is required
* whenever the operator's key was issued that way.
*/
amapApiSecret: raw.AMAP_API_SECRET || undefined,
/** Public pk.* token shipped with a managed instance; reaches the browser by design. */
mapboxToken: raw.MAPBOX_ACCESS_TOKEN || undefined,
/** CARTO basemap key; without one the tiles come back watermarked (#2054). Public too. */
cartoKey: raw.CARTO_API_KEY || undefined,
};
}
export function deriveDemo(raw: RawEnv) {
return {
enabled: parseBool(raw.DEMO_MODE) === true,
adminUser: raw.DEMO_ADMIN_USER || 'admin',
/** Raw on purpose: demo-seed defaults to admin@trek.app, demo-reset to admin@nomad.app. */
adminEmailRaw: raw.DEMO_ADMIN_EMAIL,
adminPass: raw.DEMO_ADMIN_PASS || 'admin12345',
/** False means the admin account is being created with the published default. */
adminPassSet: !!raw.DEMO_ADMIN_PASS,
};
}
export function deriveAdminBootstrap(raw: RawEnv) {
return {
email: raw.ADMIN_EMAIL,
password: raw.ADMIN_PASSWORD,
};
}
export function deriveOidc(raw: RawEnv) {
return {
issuer: raw.OIDC_ISSUER,
clientId: raw.OIDC_CLIENT_ID,
clientSecret: raw.OIDC_CLIENT_SECRET,
displayName: raw.OIDC_DISPLAY_NAME,
discoveryUrl: raw.OIDC_DISCOVERY_URL,
scope: raw.OIDC_SCOPE || 'openid email profile',
only: parseBool(raw.OIDC_ONLY) === true,
adminClaim: raw.OIDC_ADMIN_CLAIM || 'groups',
adminValue: raw.OIDC_ADMIN_VALUE,
// Unset keeps the old order (name, then preferred_username), so an instance
// that never sets it names new accounts exactly as before (#1677).
usernameClaim: raw.OIDC_USERNAME_CLAIM?.trim() || undefined,
};
}
export function deriveSmtp(raw: RawEnv) {
return {
host: raw.SMTP_HOST,
port: raw.SMTP_PORT,
user: raw.SMTP_USER,
pass: raw.SMTP_PASS,
from: raw.SMTP_FROM,
skipTlsVerify: parseBool(raw.SMTP_SKIP_TLS_VERIFY) === true,
};
}
export function deriveMcp(raw: RawEnv) {
return {
sessionTtlMs: resolveSessionTtlMs(raw.MCP_SESSION_TTL),
maxSessionsPerUser: positiveIntOr(raw.MCP_MAX_SESSION_PER_USER, 20),
sseKeepaliveMs: resolveKeepaliveMs(raw.MCP_SSE_KEEPALIVE),
rateLimitMax: positiveIntOr(raw.MCP_RATE_LIMIT, 300),
};
}
export function derivePlugins(raw: RawEnv) {
return {
/** Kill-switch is default-on: only an explicit falsy value disables (plugins/kill-switch.ts). */
enabled: parseBool(raw.TREK_PLUGINS_ENABLED) !== false,
devLink: parseBool(raw.TREK_PLUGINS_DEV_LINK) === true,
/**
* Range bypass is default-OFF: only an explicit truthy value turns the TREK-version
* gates into warnings (plugins/install/host-compat.ts). Never inferred from any
* other switch — an admin has to ask for "install it anyway" by name.
*/
ignoreTrekRange: parseBool(raw.TREK_PLUGINS_IGNORE_TREK_RANGE) === true,
dir: raw.TREK_PLUGINS_DIR,
dataDir: raw.TREK_PLUGINS_DATA_DIR,
/** Permission jail is default-on: only an explicit falsy value turns it off. */
permissionsOff: parseBool(raw.TREK_PLUGIN_PERMISSIONS) === false,
registryUrl:
raw.TREK_PLUGIN_REGISTRY_URL || 'https://raw.githubusercontent.com/liketrek/TREK-Plugins/main/dist/index.json',
/**
* The child process and plugin-sdk keep their literal `=== 'on'` check (they
* are exempt from this layer); the supervisor normalizes this boolean to the
* literal 'on' when building the child's scrubbed env.
*/
allowPrivateEgress: parseBool(raw.TREK_PLUGIN_ALLOW_PRIVATE_EGRESS) === true,
rpcBurst: numberOr(raw.TREK_PLUGIN_RPC_BURST, 60),
rpcPerSec: numberOr(raw.TREK_PLUGIN_RPC_PER_SEC, 20),
rpcInflight: numberOr(raw.TREK_PLUGIN_RPC_INFLIGHT, 16),
logBurst: numberOr(raw.TREK_PLUGIN_LOG_BURST, 50),
logPerSec: numberOr(raw.TREK_PLUGIN_LOG_PER_SEC, 10),
maxRssMb: numberOr(raw.TREK_PLUGIN_MAX_RSS_MB, 300),
};
}
export function deriveWebauthn(raw: RawEnv) {
return {
rpId: raw.WEBAUTHN_RP_ID,
origins: raw.WEBAUTHN_ORIGINS,
};
}
export function deriveIntegrations(raw: RawEnv) {
return {
unsplashAccessKey: raw.UNSPLASH_ACCESS_KEY?.trim(),
// 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.
nominatimUrl: stripTrailingSlashes(raw.NOMINATIM_URL?.trim() || 'https://nominatim.openstreetmap.org'),
/** The operator runs a geocoder of their own, so search and reverse stay on it instead of going to Photon. */
nominatimOwn: Boolean(raw.NOMINATIM_URL?.trim()),
overpassUrl: raw.OVERPASS_URL,
// 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),
/**
* One ceiling for a model call, replacing the three per-client constants
* that used to disagree. The default is deliberately generous: heavier
* parsing work should fit without a code change.
* Floored to a whole number — it reaches undici's headersTimeout, which
* rejects a fractional value.
*/
llmTimeoutMs: Math.floor(positiveNumberOr(raw.LLM_TIMEOUT_MS, 900_000)),
/**
* How long one AI-parsing answer may get. Unset leaves each client the cap it
* has always sent (4096 to OpenAI-compatible servers, 8192 to Anthropic): a
* bigger default would make the server of a small local model refuse every
* request whose prompt plus cap overruns its context (#2677).
*/
llmMaxOutputTokens: positiveIntOr(raw.LLM_MAX_OUTPUT_TOKENS, 0) || undefined,
// 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).
};
}
export function deriveFiles(raw: RawEnv) {
return {
/** Largest document a user may upload to a trip, a booking or a note (#1364). Videos keep their own cap. */
uploadLimitMb: positiveNumberOr(raw.FILE_UPLOAD_LIMIT_MB, 50),
};
}
export function deriveBackup(raw: RawEnv) {
return {
uploadLimitMb: positiveNumberOr(raw.BACKUP_UPLOAD_LIMIT_MB, 500),
maxDecompressedMb: positiveNumberOr(raw.BACKUP_MAX_DECOMPRESSED_MB, 5 * 1024),
/** backupService only bundles data/.encryption_key into archives when the key does NOT come from env. */
encryptionKeyFromEnv: !!raw.ENCRYPTION_KEY,
/** Archive restored on the first start, while no database exists yet (#1089). Null when unset or blank. */
restoreFromBackup: raw.RESTORE_FROM_BACKUP?.trim() || null,
};
}
export function deriveDb(raw: RawEnv) {
const durability = resolveDurability(raw.TREK_DB_JOURNAL_MODE, raw.TREK_DB_SYNCHRONOUS);
return {
trekDbFile: raw.TREK_DB_FILE,
/** Resolved journal_mode — WAL unless the operator asked for something else (network storage needs DELETE/TRUNCATE). */
journalMode: durability.journalMode,
/** Resolved synchronous level — NORMAL under WAL (what SQLite already does), FULL under a rollback journal. */
synchronous: durability.synchronous,
/** Complaints about unusable values; derivation stays side-effect free, so db/durability.ts logs them when it opens the file. */
durabilityWarnings: durability.warnings,
/**
* Snapshot the database before a boot migrates it. Undefined when unset: the
* caller then decides (on, except under NODE_ENV=test). false is the escape
* hatch for a data dir that cannot hold a second copy.
*/
preMigrateSnapshot: parseBool(raw.TREK_DB_PRE_MIGRATE_SNAPSHOT),
/** How many pre-migration snapshots stay in the data dir, newest first. */
preMigrateSnapshotKeep: Math.min(positiveIntOr(raw.TREK_DB_PRE_MIGRATE_SNAPSHOT_KEEP, 3), 100),
};
}
/** TREK_PLACE_PHOTO_DIR is boot-stable and owned by the `storageConfig` token (boot-derive.ts). */
export function derivePaths(raw: RawEnv) {
return {
wikiDir: raw.TREK_WIKI_DIR,
};
}
export function deriveNet(raw: RawEnv) {
return {
allowInternalNetwork: parseBool(raw.ALLOW_INTERNAL_NETWORK) === true,
allowLinkLocalIps: parseLinkLocalAllowList(raw.ALLOW_LINK_LOCAL_IPS).ips,
// The variables Node's own env proxy reads, both spellings, so the guarded
// requests below follow the same proxy as everything else (#1754).
proxy: {
http: (raw.HTTP_PROXY ?? raw.http_proxy)?.trim() || undefined,
https: (raw.HTTPS_PROXY ?? raw.https_proxy)?.trim() || undefined,
noProxy: ((raw.NO_PROXY ?? raw.no_proxy) || '')
.split(',')
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean),
},
};
}
export function derivePush(raw: RawEnv) {
return {
// Trimmed, and blank counts as unset: the schema validated the trimmed value,
// so a padded key must not reach the crypto as a different string.
vapidPublicKey: raw.VAPID_PUBLIC_KEY?.trim() || undefined,
vapidPrivateKey: raw.VAPID_PRIVATE_KEY?.trim() || undefined,
vapidSubject: raw.VAPID_SUBJECT?.trim() || undefined,
};
}
export function deriveAll(raw: RawEnv) {
return {
app: deriveApp(raw),
http: deriveHttp(raw),
session: deriveSession(raw),
managed: deriveManaged(raw),
maps: deriveMaps(raw),
demo: deriveDemo(raw),
adminBootstrap: deriveAdminBootstrap(raw),
oidc: deriveOidc(raw),
smtp: deriveSmtp(raw),
mcp: deriveMcp(raw),
plugins: derivePlugins(raw),
webauthn: deriveWebauthn(raw),
integrations: deriveIntegrations(raw),
backup: deriveBackup(raw),
files: deriveFiles(raw),
db: deriveDb(raw),
paths: derivePaths(raw),
net: deriveNet(raw),
push: derivePush(raw),
};
}
export type AppEnv = ReturnType<typeof deriveAll>;