import { z } from "zod"; const ttlPattern = /^\d+(ms|s|m|h|d)$/; /** * Runtime environment, validated with zod on first access (dev-safe defaults * so the API can boot locally without a .env file). */ const EnvSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), /** * TASK-0135: hot-path cache enablement (`"auto" | "on" | "off"`). * "auto" (default) = ON in production only. Dev/test run UNCACHED by * default — the cache's event-invalidation contract assumes every * mutation flows through the event bus, which tests (direct-Prisma * writes) and dev tooling may bypass; the integration cache suite * forces `on` explicitly to exercise the live semantics. */ PERF_CACHE: z.enum(["auto", "on", "off"]).default("auto"), PORT: z.coerce.number().int().positive().default(4000), DATABASE_URL: z.string().min(1).default("postgresql://factory:factory@localhost:5432/factory"), /** * TASK-0135 (Phase 15, pooling + replica): the READ-REPLICA connection * string. Empty (default) = no replica — the reader client IS the primary * (single-node dev/CI). When set, the tree/listing/search read paths are * routed to it (the `db.read()` helper, `@factory/database-client`); * writes, permission decisions, and the `If-Match` version CAS ALWAYS use * the primary (`db.write()`) — the replica lags. Production points this at * the PgBouncer replica pool (infrastructure/kubernetes/). */ DATABASE_REPLICA_URL: z.string().default(""), /** * TASK-0135: the per-app SHARE of the PgBouncer pool (transaction mode — * the pooler, not Prisma, owns the connections; Prisma 5.x has no client * pool-size option). The k8s secret renders this into the pgbouncer * `pool_size` per app. Documented in docs/development/perf-report.md. */ DATABASE_POOL_SIZE: z.coerce.number().int().positive().max(200).default(20), REDIS_URL: z.string().min(1).default("redis://localhost:6379"), JWT_SECRET: z.string().min(8).default("dev-secret-change-me"), ACCESS_TOKEN_TTL: z.string().regex(ttlPattern).default("15m"), REFRESH_TOKEN_TTL: z.string().regex(ttlPattern).default("30d"), /** * TASK-0129: OAuth2 authorization-code lifetimes (docs/api/authentication.md * §8). `OAUTH_CODE_TTL` — the single-use authorization code window (10 min); * `OAUTH_ACCESS_TOKEN_TTL` — the `type:"oauth"` access JWT lifetime * (900 s = 15 min, matching the doc's "900 s"). OAuth refresh tokens reuse * REFRESH_TOKEN_TTL (30 d) — no separate variable. */ OAUTH_CODE_TTL: z.string().regex(ttlPattern).default("10m"), OAUTH_ACCESS_TOKEN_TTL: z.string().regex(ttlPattern).default("900s"), CORS_ORIGINS: z.string().default("http://localhost:3000"), WEB_ORIGIN: z.string().url().default("http://localhost:3000"), /** * TASK-0131: the PUBLIC ORIGIN OF THE API ITSELF for the SSO protocol * surface (`/saml/*`, `/sso/*`; later `/scim/*`). Those are BROWSER * BINDINGS served by the API (mounted outside the `api/v1` prefix) — so * their URLs (the IdP's ACS POST target, the SP entityID, the OIDC * redirect_uri) must name the origin that actually SERVES them, i.e. the * API — not the web app. * * Empty (default) = single-origin deployments: the reference nginx front * ends web + api under one host, so WEB_ORIGIN serves both and this is * unnecessary. Two-origin dev (web :3000, api :4000 — the .env.example / * E2E layout) MUST set this to the API origin, otherwise the IdP's POST * to the ACS and the browser start/authorize GETs 404 on the web origin. */ SSO_ORIGIN: z.string().url().or(z.literal("")).default(""), S3_ENDPOINT: z.string().url().default("http://localhost:9000"), S3_REGION: z.string().default("us-east-1"), /** * TASK-0101 §1: the two PRIVATE buckets. `S3_BUCKET_FILES` — uploaded * objects (`files/…` key layout); `S3_BUCKET_THUMBS` — thumbnail/preview * artifacts (`thumbs/…` key layout), read-only-serving (still * server-authorized via signed URLs, never public). * * `S3_BUCKET` is the legacy alias for the files bucket (kept for * backward compatibility with older .env files — AGENTS.md rule 6). */ S3_BUCKET_FILES: z.string().default("factory-files"), S3_BUCKET_THUMBS: z.string().default("factory-thumbs"), S3_BUCKET: z.string().default("factory-files"), S3_ACCESS_KEY: z.string().default("minioadmin"), S3_SECRET_KEY: z.string().default("minioadmin"), /** Signed-URL lifetime (seconds) — spec default 900 (15 min). */ S3_SIGN_EXPIRY_SECONDS: z.coerce.number().int().positive().default(900), /** * TASK-0102 §3: optional ClamAV endpoint. A UNIX socket path * (`CLAMD_SOCKET=/var/run/clamav/clamd.ctl`) or TCP (CLAMAV_HOST + * CLAMAV_PORT, the Docker profile). Empty = dev mode (scan skipped with * WARN); required in production (worker boot fails — fail closed). */ CLAMD_SOCKET: z.string().default(""), CLAMAV_HOST: z.string().default(""), CLAMAV_PORT: z.coerce.number().int().positive().default(3310), SEARCH_URL: z.string().url().default("http://localhost:9200"), SEARCH_USERNAME: z.string().default("admin"), SEARCH_PASSWORD: z.string().default("changeme"), AI_API_KEY: z.string().default(""), AI_PROVIDER: z.string().default("openai"), AI_BASE_URL: z.string().default(""), AI_MODEL: z.string().default("gpt-4o-mini"), SMTP_HOST: z.string().default(""), SMTP_PORT: z.coerce.number().int().positive().default(587), SMTP_USER: z.string().default(""), SMTP_PASS: z.string().default(""), WEBHOOK_BASE_URL: z.string().url().default("http://localhost:4000/api/v1"), MASTER_ENCRYPTION_KEY: z.string().default(""), /** TASK-0130: allow webhook delivery URLs targeting private/loopback/ link-local addresses (dev override; production must stay empty). */ WEBHOOK_ALLOW_PRIVATE: z.enum(["true", "false"]).default("false"), /** * Phase 11 (TASK-0110): AES-256-GCM key for wrapping incoming-webhook * secrets at rest (`config.secretEncrypted`). The spec names a dedicated * KMS key; when unset we fall back to MASTER_ENCRYPTION_KEY (recorded * decision — D5, docs/architecture/automation.md) so single-key * deployments keep working. */ AUTOMATION_WEBHOOK_KMS_KEY: z.string().default(""), RATE_LIMIT_API: z.coerce.number().int().positive().default(100), RATE_LIMIT_AUTH: z.coerce.number().int().positive().default(10), /** * TASK-0127: per-API-key default (requests/min) for a key that has no * explicit `rateLimit` set — the published contract * (docs/api/authentication.md §11: "Per API key: 600 req/min, or the * key's rateLimit if set"). Distinct from RATE_LIMIT_API (the generic * per-credential window for users/IPs). The key's own `rateLimit` always * wins (set at key creation). */ RATE_LIMIT_API_KEY: z.coerce.number().int().positive().default(600), /** * Google reCAPTCHA siteverify secret (Phase 10 TASK-0107: the public form * page carries the widget slot; while empty the API's verification is the * documented STUB — it skips with a WARN log). Real verification lands in * Phase 13+ against this key. */ RECAPTCHA_SECRET: z.string().default(""), /** * Test-only debug events token (Phase 10 TASK-0109): `GET * /api/v1/debug/events` reads the Redis event stream so the E2E suite can * assert domain events end-to-end. The route 404s unless NODE_ENV=test AND * requires X-Debug-Token = this value; empty (default, incl. production) * = the endpoint is closed (an empty token never matches). */ TEST_DEBUG_TOKEN: z.string().default(""), /** * Shared service token for the internal endpoints (`/internal/*` — TASK-0092 * slice 2). The realtime gateway authenticates to the api's internal block * endpoints (hydration read + the flush's CAS persist) with this bearer * token. Empty in dev (the internal endpoints are then CLOSED — the * gateway's HTTP store degrades to the in-memory seam, exactly like * `REALTIME_DB=0`); REQUIRED to open them, so the default is fail-closed * (the task file's recorded decision; the public surface is never opened * by accident). */ INTERNAL_SERVICE_TOKEN: z.string().default(""), /** * Test-only seam (TASK-0095 S9): when set to `"1"`, the internal surface * opens `POST /internal/workspaces/:workspaceId/members` — add a registered * user to a workspace directly (no invite mail). It is guarded by the SAME * fail-closed `InternalServiceGuard` (`INTERNAL_SERVICE_TOKEN`) as the rest * of `/internal/*`, so it is closed unless BOTH `INTERNAL_SERVICE_TOKEN` is * configured AND this flag is enabled. Production never sets either; the * only consumers are the integration suite (in-process app) and the CI e2e * job. Recorded in docs/tasks/phase-07-collaboration.md (TASK-0095 S9). */ INTERNAL_TEST_SEAM: z.string().default("") }); export type Env = z.infer; let cached: Env | undefined; /** Singleton, lazily validated environment. */ export function loadEnv(overrides: NodeJS.ProcessEnv = process.env): Env { if (cached) return cached; const parsed = EnvSchema.safeParse(overrides); if (!parsed.success) { // Fail fast with a readable report — booting with a bad env is a deploy error. const msg = parsed.error.issues .map((i) => ` ${i.path.join(".")}: ${i.message}`) .join("\n"); throw new Error(`Invalid environment:\n${msg}`); } cached = parsed.data; return cached; } export function resetEnvCache(): void { cached = undefined; }