Files
moeka-project/apps/server/src/libs/env.ts
T

227 lines
9.6 KiB
TypeScript

import type { InferOutput } from 'valibot'
import { Buffer } from 'node:buffer'
import { env, exit } from 'node:process'
import { useLogger } from '@guiiai/logg'
import { injeca } from 'injeca'
import { check, integer, maxValue, minValue, nonEmpty, object, optional, parse, pipe, string, transform } from 'valibot'
/**
* Parses `ADDITIONAL_TRUSTED_ORIGINS`: comma-separated absolute origins used for
* CORS (`/api/*`) and request-derived trusted bases (e.g. Stripe return URLs).
* Each segment is normalized via `URL.origin` so trailing slashes are stripped.
*
* Before:
* - `" https://10.0.0.129:5273/ , https://198.18.0.1:5273 "`
*
* After:
* - `["https://10.0.0.129:5273", "https://198.18.0.1:5273"]`
*/
export function parseAdditionalTrustedOriginsEnv(raw: string): string[] {
const trimmed = raw.trim()
if (!trimmed)
return []
const seen = new Set<string>()
const out: string[] = []
for (const part of trimmed.split(',')) {
const entry = part.trim()
if (!entry)
continue
let normalized: string
try {
normalized = new URL(entry).origin
}
catch {
throw new TypeError(`ADDITIONAL_TRUSTED_ORIGINS: invalid URL origin segment "${entry}"`)
}
if (!seen.has(normalized)) {
seen.add(normalized)
out.push(normalized)
}
}
return out
}
function optionalIntegerFromString(defaultValue: number, envKey: string, minimum: number) {
return optional(
pipe(
string(),
nonEmpty(`${envKey} must not be empty`),
transform(input => Number(input)),
integer(`${envKey} must be an integer`),
minValue(minimum, `${envKey} must be at least ${minimum}`),
),
String(defaultValue),
)
}
function optionalNumberFromString(defaultValue: number, envKey: string, minimum: number, maximum: number) {
return optional(
pipe(
string(),
nonEmpty(`${envKey} must not be empty`),
transform(input => Number(input)),
minValue(minimum, `${envKey} must be at least ${minimum}`),
maxValue(maximum, `${envKey} must be at most ${maximum}`),
),
String(defaultValue),
)
}
const EnvSchema = object({
HOST: optional(string(), '0.0.0.0'),
PORT: optionalIntegerFromString(3000, 'PORT', 1),
API_SERVER_URL: optional(string(), 'http://localhost:3000'),
// Standalone auth UI base URL. The server keeps `/auth/*` as the historical
// entrypoint and redirects those requests here after ui-server-auth moved out
// of the server image.
AUTH_UI_URL: optional(string(), 'https://accounts.airi.build/ui'),
// Standalone admin UI base URL. The server keeps `/admin/*` as the historical
// entrypoint and redirects those requests here after the admin UI moved to
// the standalone proj-airi repository.
ADMIN_UI_URL: optional(string(), 'https://admin.airi.build'),
// Canonical user-facing web app origin. Used as the Stripe redirect base
// (success_url / cancel_url / portal return_url) when a request has no trusted
// browser origin — notably the Electron desktop renderer, which loads from
// file:// and sends no usable web origin. Web/mobile requests keep returning to
// their own origin; only origin-less clients fall back to this.
WEB_APP_URL: optional(string(), 'https://airi.moeru.ai'),
// Comma-separated exact origins (e.g. Capacitor dev server `https://10.x:5273`).
// Prefer this over broad private-IP regex heuristics in production-like configs.
ADDITIONAL_TRUSTED_ORIGINS: optional(
pipe(
string(),
transform(raw => parseAdditionalTrustedOriginsEnv(raw)),
),
'',
),
DATABASE_URL: pipe(string(), nonEmpty('DATABASE_URL is required')),
REDIS_URL: pipe(string(), nonEmpty('REDIS_URL is required')),
// Required: signs session cookies and encrypts JWKS private keys in DB.
// Must be stable across deploys/instances, otherwise every redeploy invalidates
// all existing sessions and forces users to re-login.
BETTER_AUTH_SECRET: pipe(string(), nonEmpty('BETTER_AUTH_SECRET is required')),
AUTH_GOOGLE_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_ID is required')),
AUTH_GOOGLE_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_SECRET is required')),
AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')),
AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')),
AUTH_APPLE_CLIENT_ID: optional(string(), ''),
AUTH_APPLE_TEAM_ID: optional(string(), ''),
AUTH_APPLE_KEY_ID: optional(string(), ''),
AUTH_APPLE_PRIVATE_KEY_PEM: optional(
pipe(
string(),
// Deployment dashboards commonly store multiline secrets with escaped
// newlines. jose's PKCS8 importer requires the original PEM layout.
transform(raw => raw.replaceAll(String.raw`\n`, '\n')),
),
'',
),
// Testing-only bearer token bypass. Keep unset in production. When set,
// Authorization: Bearer $TEST_AUTH_TOKEN resolves to the virtual user below
// through resolveRequestAuth without creating a better-auth session row.
TEST_AUTH_TOKEN: optional(string(), ''),
TEST_AUTH_USER_ID: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_ID must not be empty when set')), 'test-user'),
TEST_AUTH_USER_EMAIL: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_EMAIL must not be empty when set')), 'test@example.com'),
TEST_AUTH_USER_NAME: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_NAME must not be empty when set')), 'Test User'),
TEST_AUTH_USER_ROLE: optional(string(), ''),
// Resend transactional email. RESEND_API_KEY required when emailAndPassword
// sign-up / forgot-password / change-email / magic-link is exercised. Service
// boots without it but those flows will throw at send-time.
RESEND_API_KEY: optional(string(), ''),
// From address must be a verified Resend sender (e.g. `noreply@your-domain`).
RESEND_FROM_EMAIL: optional(string(), 'noreply@airi.moeru.ai'),
// Optional friendly name; rendered as `Name <email>` per Resend's RFC 5322 display-name format.
RESEND_FROM_NAME: optional(string(), 'Project AIRI'),
STRIPE_SECRET_KEY: optional(string()),
STRIPE_WEBHOOK_SECRET: optional(string()),
// LLM/TTS gateway is fully internalised by the in-process router; provider
// baseURLs live per-upstream inside LLM_ROUTER_CONFIG, and the default chat /
// tts model aliases moved to configKV (DEFAULT_CHAT_MODEL / DEFAULT_TTS_MODEL)
// so they're hot-swappable via Pub/Sub invalidation. No env entries needed
// here.
// Envelope-encryption master key for in-process LLM/TTS router (KTD-5).
// Stored as base64-encoded 32 random bytes. Validator decodes + asserts the
// 32-byte length at parse time so a misconfigured key fails the deploy
// rather than passing readiness and breaking on first router request.
// Required: the router has no fallback path, so an unset master key means
// chat completions cannot serve at all.
LLM_ROUTER_MASTER_KEY: pipe(
string(),
nonEmpty('LLM_ROUTER_MASTER_KEY is required'),
transform(b64 => Buffer.from(b64, 'base64')),
check(buf => buf.length === 32, 'LLM_ROUTER_MASTER_KEY must decode to exactly 32 bytes (base64-encoded 32-byte random)'),
),
// Optional second master key used only during rotation: encrypts under
// LLM_ROUTER_MASTER_KEY (new), retries decrypt against LLM_ROUTER_MASTER_KEY_PREVIOUS
// (old). Drop after re-encrypting every stored ciphertext.
LLM_ROUTER_MASTER_KEY_PREVIOUS: optional(pipe(
string(),
nonEmpty('LLM_ROUTER_MASTER_KEY_PREVIOUS must not be empty when set'),
transform(b64 => Buffer.from(b64, 'base64')),
check(buf => buf.length === 32, 'LLM_ROUTER_MASTER_KEY_PREVIOUS must decode to exactly 32 bytes when set'),
)),
// Database pool
DB_POOL_MAX: optionalIntegerFromString(20, 'DB_POOL_MAX', 1),
DB_POOL_IDLE_TIMEOUT_MS: optionalIntegerFromString(30000, 'DB_POOL_IDLE_TIMEOUT_MS', 1),
DB_POOL_CONNECTION_TIMEOUT_MS: optionalIntegerFromString(5000, 'DB_POOL_CONNECTION_TIMEOUT_MS', 1),
DB_POOL_KEEPALIVE_INITIAL_DELAY_MS: optionalIntegerFromString(10000, 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS', 1),
// PostHog product-event forwarding (signup / payment / subscription facts).
// Defaults to the shared AIRI project key (same browser-safe phc_* key the
// client surfaces embed in posthog.config.ts), so forwarding is on out of
// the box. Set to an empty string to disable; Postgres `product_events`
// stays the source of truth either way.
POSTHOG_PROJECT_KEY: optional(string(), 'phc_pzjziJjrVZpa9SqnQqq0QEKvkmuCPH7GDTA6TbRTEf9'), // cspell:disable-line
POSTHOG_API_HOST: optional(string(), 'https://t.airi.build'),
// OpenTelemetry
OTEL_SERVICE_NAMESPACE: optional(string(), 'airi'),
OTEL_SERVICE_NAME: optional(string(), 'server'),
OTEL_TRACES_SAMPLING_RATIO: optionalNumberFromString(1, 'OTEL_TRACES_SAMPLING_RATIO', 0, 1),
OTEL_EXPORTER_OTLP_ENDPOINT: optional(string()),
OTEL_EXPORTER_OTLP_HEADERS: optional(string()),
OTEL_DEBUG: optional(string()),
// Admin allowlist for /api/admin/* routes. Comma-separated email addresses.
// Match is case-insensitive, but the user must also have `email_verified = true`
// — otherwise an attacker could register a fresh account with the admin email
// before verification and slip past the check.
// Empty (default) = no one is admin — production safe by default.
// Example: ADMIN_EMAILS=alice@example.com,bob@example.com
ADMIN_EMAILS: optional(string(), ''),
})
export type Env = InferOutput<typeof EnvSchema>
export function parseEnv(inputEnv: Record<string, string> | typeof env): Env {
try {
return parse(EnvSchema, inputEnv)
}
catch (err) {
useLogger().withError(err).error('Invalid environment variables')
exit(1)
}
}
export const parsedEnv = injeca.provide('env', () => parseEnv(env))