mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
f901982903
The OpenAI-compatible client always asked for 4096 tokens, and a document whose bookings needed more came back as "no reservations" with nothing that pointed at the cap. LLM_MAX_OUTPUT_TOKENS now sets the cap for every provider; unset keeps 4096 and 8192 so a small local model's context still fits. A finish_reason of "length" is reported like Anthropic's max_tokens stop, naming the limit and the variable that raises it. Closes #2677
214 lines
8.4 KiB
TypeScript
214 lines
8.4 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,
|
|
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,
|
|
TREK_PLACES_URL: url,
|
|
TREK_PLACES_ENABLED: boolStr,
|
|
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,
|
|
});
|