mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
256f8c08e2
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.
220 lines
8.7 KiB
TypeScript
220 lines
8.7 KiB
TypeScript
/**
|
|
* The single Zod catalog of TREK's environment surface, validated ONCE at boot
|
|
* (see env.ts / boot-validate.ts) — never per-request and never inside
|
|
* buildApp(), so the test harness (which boots dozens of apps per run with a
|
|
* minimal env) is unaffected.
|
|
*
|
|
* Fail-fast contract: a variable that is UNSET (or blank) always falls back to
|
|
* its default — only a PRESENT-but-malformed value aborts startup. Keys not
|
|
* listed here pass through untouched (Zod strips unknown keys, it does not
|
|
* reject them).
|
|
*/
|
|
import {
|
|
isP256PrivateKey,
|
|
isServiceKeyShape,
|
|
isUncompressedP256Key,
|
|
isVapidSubject,
|
|
parseDurationMs,
|
|
parseLinkLocalAllowList,
|
|
} from './parsers';
|
|
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared';
|
|
|
|
import { z } from 'zod';
|
|
|
|
/** Present-but-malformed fails; unset/blank always passes (defaults apply). */
|
|
function optionalWith(test: (v: string) => boolean, message: string) {
|
|
return z
|
|
.string()
|
|
.optional()
|
|
.refine((v) => v === undefined || v.trim() === '' || test(v.trim()), message);
|
|
}
|
|
|
|
const anyString = z.string().optional();
|
|
const boolStr = optionalWith(
|
|
(v) => /^(true|false|on|off|yes|no|1|0)$/i.test(v),
|
|
'must be a boolean-like value (true/false, 1/0, on/off, yes/no)',
|
|
);
|
|
const integer = (min: number, max = Number.MAX_SAFE_INTEGER, msg = `must be an integer between ${min} and ${max}`) =>
|
|
optionalWith((v) => {
|
|
const n = Number(v);
|
|
return Number.isInteger(n) && n >= min && n <= max;
|
|
}, msg);
|
|
const positiveNumber = optionalWith((v) => {
|
|
const n = Number(v);
|
|
return Number.isFinite(n) && n > 0;
|
|
}, 'must be a positive number');
|
|
const url = optionalWith((v) => {
|
|
try {
|
|
new URL(v);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, 'must be a valid URL (with protocol)');
|
|
const duration = optionalWith((v) => parseDurationMs(v) != null, 'must be a duration like "1h", "7d" or "30d"');
|
|
const oneOf = (values: string[]) =>
|
|
optionalWith((v) => values.includes(v.toLowerCase()), `must be one of: ${values.join(', ')}`);
|
|
|
|
export const envSchema = z.object({
|
|
// Core runtime
|
|
PORT: integer(1, 65535, 'must be a port number (1-65535)'),
|
|
HOST: anyString,
|
|
// NODE_ENV and TZ are deliberately unvalidated: non-standard values (e.g.
|
|
// NODE_ENV=staging) behave as "not production" today and must keep booting.
|
|
// Surrounding whitespace on NODE_ENV is trimmed before this runs (env.ts).
|
|
NODE_ENV: anyString,
|
|
TZ: anyString,
|
|
LOG_LEVEL: oneOf(['error', 'warn', 'info', 'debug']),
|
|
APP_VERSION: anyString,
|
|
APP_URL: url,
|
|
ALLOWED_ORIGINS: anyString,
|
|
// Candidates lowercased so mixed-case codes (zh-TW) validate case-insensitively.
|
|
DEFAULT_LANGUAGE: oneOf(SUPPORTED_LANGUAGE_CODES.map((c) => c.toLowerCase())),
|
|
|
|
// Security / session
|
|
ENCRYPTION_KEY: anyString,
|
|
SESSION_DURATION: duration,
|
|
SESSION_DURATION_REMEMBER: duration,
|
|
COOKIE_SECURE: boolStr,
|
|
FORCE_HTTPS: boolStr,
|
|
HSTS_INCLUDE_SUBDOMAINS: boolStr,
|
|
TRUST_PROXY: integer(0, 2 ** 31, 'must be an integer (number of trusted proxy hops)'),
|
|
// Capped below Node's 300 s request timeout, which the headers timeout (one
|
|
// second above this) has to stay under.
|
|
HTTP_KEEP_ALIVE_TIMEOUT_MS: integer(1_000, 290_000, 'must be a whole number of milliseconds between 1000 and 290000'),
|
|
ALLOW_INTERNAL_NETWORK: boolStr,
|
|
ALLOW_LINK_LOCAL_IPS: optionalWith(
|
|
(v) => parseLinkLocalAllowList(v).invalid.length === 0,
|
|
'must be a comma-separated list of IPv4 addresses in 169.254.0.0/16, such as 169.254.1.2; ' +
|
|
'169.254.169.x and 169.254.170.x (cloud metadata) cannot be listed',
|
|
),
|
|
IDEMPOTENCY_TTL_SECONDS: positiveNumber,
|
|
|
|
// OIDC
|
|
OIDC_ISSUER: url,
|
|
OIDC_CLIENT_ID: anyString,
|
|
OIDC_CLIENT_SECRET: anyString,
|
|
OIDC_DISPLAY_NAME: anyString,
|
|
OIDC_DISCOVERY_URL: url,
|
|
OIDC_SCOPE: anyString,
|
|
OIDC_ONLY: boolStr,
|
|
OIDC_ADMIN_CLAIM: anyString,
|
|
OIDC_ADMIN_VALUE: anyString,
|
|
OIDC_USERNAME_CLAIM: anyString,
|
|
|
|
// SMTP
|
|
SMTP_HOST: anyString,
|
|
SMTP_PORT: integer(1, 65535, 'must be a port number (1-65535)'),
|
|
SMTP_USER: anyString,
|
|
SMTP_PASS: anyString,
|
|
SMTP_FROM: anyString,
|
|
SMTP_SKIP_TLS_VERIFY: boolStr,
|
|
|
|
// WebAuthn
|
|
WEBAUTHN_RP_ID: anyString,
|
|
WEBAUTHN_ORIGINS: anyString,
|
|
|
|
// Web Push (VAPID). All optional: without them the server keeps a pair of its
|
|
// own in the database. Only the shape is checked here; whether the two keys
|
|
// belong together is checked where they are used, which turns push off while
|
|
// they do not (never signing with a stored pair in their place) and says so
|
|
// in the log instead of refusing to boot.
|
|
VAPID_PUBLIC_KEY: optionalWith(
|
|
isUncompressedP256Key,
|
|
'must be a base64url-encoded P-256 public key (65 bytes, uncompressed, starting with 0x04)',
|
|
),
|
|
VAPID_PRIVATE_KEY: optionalWith(isP256PrivateKey, 'must be a base64url-encoded P-256 private key (32 bytes)'),
|
|
VAPID_SUBJECT: optionalWith(isVapidSubject, 'must be a mailto: address or an https:// URL'),
|
|
|
|
// MCP
|
|
MCP_SESSION_TTL: positiveNumber,
|
|
MCP_MAX_SESSION_PER_USER: positiveNumber,
|
|
MCP_SSE_KEEPALIVE: integer(0, Number.MAX_SAFE_INTEGER, 'must be a non-negative integer (seconds, 0 disables)'),
|
|
MCP_RATE_LIMIT: positiveNumber,
|
|
|
|
// Integrations
|
|
UNSPLASH_ACCESS_KEY: anyString,
|
|
TRANSIT_API_URL: url,
|
|
NOMINATIM_URL: url,
|
|
// OVERPASS_URL accepts a comma-separated endpoint list and silently drops
|
|
// non-http(s) entries today — left unvalidated to keep that behavior.
|
|
OVERPASS_URL: anyString,
|
|
OVERPASS_TIMEOUT_MS: positiveNumber,
|
|
// Whole milliseconds, and inside setTimeout's 32-bit range. A fractional value
|
|
// would floor to 0 and abort every model call on the next tick; anything past
|
|
// 2^31-1 makes Node clamp the delay to 1 ms and do the same. Both refuse at
|
|
// boot rather than degrading into a timeout of zero.
|
|
LLM_TIMEOUT_MS: integer(1, 2_147_483_647, 'must be a whole number of milliseconds between 1 and 2147483647'),
|
|
// Below 256 not even one reservation fits, so a value that small is a typo, not a choice.
|
|
LLM_MAX_OUTPUT_TOKENS: integer(256, 1_000_000, 'must be a whole number of tokens between 256 and 1000000'),
|
|
KITINERARY_EXTRACTOR_PATH: anyString,
|
|
// The OS search path. Not configuration anybody sets for TREK — it is here so
|
|
// the kitinerary probe can resolve its binary to an absolute path itself
|
|
// instead of handing an unqualified name to exec, which would re-resolve
|
|
// through whatever PATH held at that moment.
|
|
PATH: anyString,
|
|
Path: anyString,
|
|
|
|
// Data / paths
|
|
TREK_DB_FILE: anyString,
|
|
// The two SQLite pragmas are deliberately unvalidated: a typo here must fall
|
|
// back and warn rather than abort boot, because reset-admin.js — the way out
|
|
// of a locked-out instance — reads the same variables and is not covered by
|
|
// this schema at all. Resolution lives in parsers.resolveDurability().
|
|
TREK_DB_JOURNAL_MODE: anyString,
|
|
TREK_DB_SYNCHRONOUS: anyString,
|
|
// The copy of the database taken before a boot applies pending migrations.
|
|
// Unlike the two pragmas a typo here refuses the boot: the switch exists to
|
|
// turn a safety net off, and a misspelt "off" must not leave it on silently
|
|
// or a misspelt "on" turn it off.
|
|
TREK_DB_PRE_MIGRATE_SNAPSHOT: boolStr,
|
|
TREK_DB_PRE_MIGRATE_SNAPSHOT_KEEP: integer(1, 100),
|
|
TREK_WIKI_DIR: anyString,
|
|
TREK_PLACE_PHOTO_DIR: anyString,
|
|
BACKUP_UPLOAD_LIMIT_MB: positiveNumber,
|
|
FILE_UPLOAD_LIMIT_MB: positiveNumber,
|
|
BACKUP_MAX_DECOMPRESSED_MB: positiveNumber,
|
|
// A backup archive to restore on the very first start, before any setup (#1089).
|
|
RESTORE_FROM_BACKUP: anyString,
|
|
|
|
// Admin / demo
|
|
ADMIN_EMAIL: anyString,
|
|
ADMIN_PASSWORD: anyString,
|
|
TREK_MANAGED: boolStr,
|
|
PLACES_API_BASE: url,
|
|
GOOGLE_LIST_DATA_BASE: url,
|
|
TREK_PLACES_URL: url,
|
|
TREK_PLACES_ENABLED: boolStr,
|
|
TREK_SERVICES: oneOf(['auto', 'off']),
|
|
TREK_SERVICES_KEY: optionalWith(isServiceKeyShape, 'must be a TREK services key (tk1.<kid>.<payload>.<signature>)'),
|
|
TREK_SERVICES_HOSTS: anyString,
|
|
TREK_PHOTON_URL: url,
|
|
PLACES_API_KEY: anyString,
|
|
AMAP_API_BASE: url,
|
|
AMAP_API_KEY: anyString,
|
|
AMAP_API_SECRET: anyString,
|
|
MAPBOX_ACCESS_TOKEN: anyString,
|
|
CARTO_API_KEY: anyString,
|
|
DEMO_MODE: boolStr,
|
|
DEMO_ADMIN_USER: anyString,
|
|
DEMO_ADMIN_EMAIL: anyString,
|
|
DEMO_ADMIN_PASS: anyString,
|
|
|
|
// API docs / plugins
|
|
TREK_API_DOCS_ENABLED: boolStr,
|
|
TREK_PLUGINS_ENABLED: boolStr,
|
|
TREK_PLUGINS_DEV_LINK: boolStr,
|
|
TREK_PLUGINS_IGNORE_TREK_RANGE: boolStr,
|
|
TREK_PLUGINS_DIR: anyString,
|
|
TREK_PLUGINS_DATA_DIR: anyString,
|
|
TREK_PLUGIN_PERMISSIONS: boolStr,
|
|
TREK_PLUGIN_ALLOW_PRIVATE_EGRESS: boolStr,
|
|
TREK_PLUGIN_REGISTRY_URL: url,
|
|
TREK_PLUGIN_RPC_BURST: positiveNumber,
|
|
TREK_PLUGIN_RPC_PER_SEC: positiveNumber,
|
|
TREK_PLUGIN_RPC_INFLIGHT: positiveNumber,
|
|
TREK_PLUGIN_LOG_BURST: positiveNumber,
|
|
TREK_PLUGIN_LOG_PER_SEC: positiveNumber,
|
|
TREK_PLUGIN_MAX_RSS_MB: positiveNumber,
|
|
});
|