mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
ba1504d6e9
Eight registerAs tokens existed while every consumer read the same values through readEnv() instead, so the boot-stable layer the repo rules describe was unused and nothing said which path owned a variable. FORCE_HTTPS, for instance, was frozen by the middleware through the http token and read live by the cookie code at the same time. A variable is now owned by exactly one side. TRUST_PROXY and HSTS_INCLUDE_SUBDOMAINS belong to httpConfig, which buildApp() already injects into the pre-init middleware, and TREK_PLACE_PHOTO_DIR to the new storageConfig, which the storage registry now injects instead of reading it through RuntimeEnvService. Their derive functions moved to boot-derive.ts, which deriveAll() never calls. The six tokens nothing injected are gone: their values are needed before the container exists (config.ts, the database, src/mcp, the SSRF guard) and stay on readEnv(). config-ownership.test.ts records the keys each side reads through a Proxy and fails on a variable read through both, on a token nothing injects and on a variable the schema does not validate. env-reference.test.ts holds server/.env.example to the schema, which had drifted by 34 variables (SMTP, WebAuthn, plugins, MCP session tuning, TREK_MANAGED and others); they are documented now. jwt-verify.ts no longer cites a middleware file that is gone.
407 lines
17 KiB
TypeScript
407 lines
17 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 { SUPPORTED_LANGUAGE_CODES } from '@trek/shared';
|
|
import {
|
|
csvList,
|
|
csvListFiltered,
|
|
numberOr,
|
|
parseBool,
|
|
parseDurationMs,
|
|
parseLinkLocalAllowList,
|
|
positiveIntOr,
|
|
positiveNumberOr,
|
|
resolveDurability,
|
|
resolveKeepaliveMs,
|
|
resolveSessionTtlMs,
|
|
stripTrailingSlashes,
|
|
} from './parsers';
|
|
import { imageVersion } from './image-version';
|
|
|
|
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,
|
|
/**
|
|
* 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(),
|
|
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.
|
|
nominatimUrl: stripTrailingSlashes(raw.NOMINATIM_URL?.trim() || 'https://nominatim.openstreetmap.org'),
|
|
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),
|
|
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
|
|
* 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)),
|
|
// 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),
|
|
};
|
|
}
|
|
|
|
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>;
|