1import { z } from "zod"; 2 3const ttlPattern = /^\d+(ms|s|m|h|d)$/; 4 5/** 6 * Runtime environment, validated with zod on first access (dev-safe defaults 7 * so the API can boot locally without a .env file). 8 */ 9const EnvSchema = z.object({ 10 NODE_ENV: z.enum(["development", "test", "production"]).default("development"), 11 /** 12 * TASK-0135: hot-path cache enablement (`"auto" | "on" | "off"`). 13 * "auto" (default) = ON in production only. Dev/test run UNCACHED by 14 * default — the cache's event-invalidation contract assumes every 15 * mutation flows through the event bus, which tests (direct-Prisma 16 * writes) and dev tooling may bypass; the integration cache suite 17 * forces `on` explicitly to exercise the live semantics. 18 */ 19 PERF_CACHE: z.enum(["auto", "on", "off"]).default("auto"), 20 PORT: z.coerce.number().int().positive().default(4000), 21 DATABASE_URL: z.string().min(1).default("postgresql://factory:factory@localhost:5432/factory"), 22 /** 23 * TASK-0135 (Phase 15, pooling + replica): the READ-REPLICA connection 24 * string. Empty (default) = no replica — the reader client IS the primary 25 * (single-node dev/CI). When set, the tree/listing/search read paths are 26 * routed to it (the `db.read()` helper, `@factory/database-client`); 27 * writes, permission decisions, and the `If-Match` version CAS ALWAYS use 28 * the primary (`db.write()`) — the replica lags. Production points this at 29 * the PgBouncer replica pool (infrastructure/kubernetes/). 30 */ 31 DATABASE_REPLICA_URL: z.string().default(""), 32 /** 33 * TASK-0135: the per-app SHARE of the PgBouncer pool (transaction mode — 34 * the pooler, not Prisma, owns the connections; Prisma 5.x has no client 35 * pool-size option). The k8s secret renders this into the pgbouncer 36 * `pool_size` per app. Documented in docs/development/perf-report.md. 37 */ 38 DATABASE_POOL_SIZE: z.coerce.number().int().positive().max(200).default(20), 39 REDIS_URL: z.string().min(1).default("redis://localhost:6379"), 40 JWT_SECRET: z.string().min(8).default("dev-secret-change-me"), 41 ACCESS_TOKEN_TTL: z.string().regex(ttlPattern).default("15m"), 42 REFRESH_TOKEN_TTL: z.string().regex(ttlPattern).default("30d"), 43 /** 44 * TASK-0129: OAuth2 authorization-code lifetimes (docs/api/authentication.md 45 * §8). `OAUTH_CODE_TTL` — the single-use authorization code window (10 min); 46 * `OAUTH_ACCESS_TOKEN_TTL` — the `type:"oauth"` access JWT lifetime 47 * (900 s = 15 min, matching the doc's "900 s"). OAuth refresh tokens reuse 48 * REFRESH_TOKEN_TTL (30 d) — no separate variable. 49 */ 50 OAUTH_CODE_TTL: z.string().regex(ttlPattern).default("10m"), 51 OAUTH_ACCESS_TOKEN_TTL: z.string().regex(ttlPattern).default("900s"), 52 CORS_ORIGINS: z.string().default("http://localhost:3000"), 53 WEB_ORIGIN: z.string().url().default("http://localhost:3000"), 54 /** 55 * TASK-0131: the PUBLIC ORIGIN OF THE API ITSELF for the SSO protocol 56 * surface (`/saml/*`, `/sso/*`; later `/scim/*`). Those are BROWSER 57 * BINDINGS served by the API (mounted outside the `api/v1` prefix) — so 58 * their URLs (the IdP's ACS POST target, the SP entityID, the OIDC 59 * redirect_uri) must name the origin that actually SERVES them, i.e. the 60 * API — not the web app. 61 * 62 * Empty (default) = single-origin deployments: the reference nginx front 63 * ends web + api under one host, so WEB_ORIGIN serves both and this is 64 * unnecessary. Two-origin dev (web :3000, api :4000 — the .env.example / 65 * E2E layout) MUST set this to the API origin, otherwise the IdP's POST 66 * to the ACS and the browser start/authorize GETs 404 on the web origin. 67 */ 68 SSO_ORIGIN: z.string().url().or(z.literal("")).default(""), 69 S3_ENDPOINT: z.string().url().default("http://localhost:9000"), 70 S3_REGION: z.string().default("us-east-1"), 71 /** 72 * TASK-0101 §1: the two PRIVATE buckets. `S3_BUCKET_FILES` — uploaded 73 * objects (`files/…` key layout); `S3_BUCKET_THUMBS` — thumbnail/preview 74 * artifacts (`thumbs/…` key layout), read-only-serving (still 75 * server-authorized via signed URLs, never public). 76 * 77 * `S3_BUCKET` is the legacy alias for the files bucket (kept for 78 * backward compatibility with older .env files — AGENTS.md rule 6). 79 */ 80 S3_BUCKET_FILES: z.string().default("factory-files"), 81 S3_BUCKET_THUMBS: z.string().default("factory-thumbs"), 82 S3_BUCKET: z.string().default("factory-files"), 83 S3_ACCESS_KEY: z.string().default("minioadmin"), 84 S3_SECRET_KEY: z.string().default("minioadmin"), 85 /** Signed-URL lifetime (seconds) — spec default 900 (15 min). */ 86 S3_SIGN_EXPIRY_SECONDS: z.coerce.number().int().positive().default(900), 87 /** 88 * TASK-0102 §3: optional ClamAV endpoint. A UNIX socket path 89 * (`CLAMD_SOCKET=/var/run/clamav/clamd.ctl`) or TCP (CLAMAV_HOST + 90 * CLAMAV_PORT, the Docker profile). Empty = dev mode (scan skipped with 91 * WARN); required in production (worker boot fails — fail closed). 92 */ 93 CLAMD_SOCKET: z.string().default(""), 94 CLAMAV_HOST: z.string().default(""), 95 CLAMAV_PORT: z.coerce.number().int().positive().default(3310), 96 SEARCH_URL: z.string().url().default("http://localhost:9200"), 97 SEARCH_USERNAME: z.string().default("admin"), 98 SEARCH_PASSWORD: z.string().default("changeme"), 99 AI_API_KEY: z.string().default(""), 100 AI_PROVIDER: z.string().default("openai"), 101 AI_BASE_URL: z.string().default(""), 102 AI_MODEL: z.string().default("gpt-4o-mini"), 103 SMTP_HOST: z.string().default(""), 104 SMTP_PORT: z.coerce.number().int().positive().default(587), 105 SMTP_USER: z.string().default(""), 106 SMTP_PASS: z.string().default(""), 107 WEBHOOK_BASE_URL: z.string().url().default("http://localhost:4000/api/v1"), 108 MASTER_ENCRYPTION_KEY: z.string().default(""), 109 110 /** TASK-0130: allow webhook delivery URLs targeting private/loopback/ link-local addresses (dev override; production must stay empty). */ 111 WEBHOOK_ALLOW_PRIVATE: z.enum(["true", "false"]).default("false"), 112 /** 113 * Phase 11 (TASK-0110): AES-256-GCM key for wrapping incoming-webhook 114 * secrets at rest (`config.secretEncrypted`). The spec names a dedicated 115 * KMS key; when unset we fall back to MASTER_ENCRYPTION_KEY (recorded 116 * decision — D5, docs/architecture/automation.md) so single-key 117 * deployments keep working. 118 */ 119 AUTOMATION_WEBHOOK_KMS_KEY: z.string().default(""), 120 RATE_LIMIT_API: z.coerce.number().int().positive().default(100), 121 RATE_LIMIT_AUTH: z.coerce.number().int().positive().default(10), 122 /** 123 * TASK-0127: per-API-key default (requests/min) for a key that has no 124 * explicit `rateLimit` set — the published contract 125 * (docs/api/authentication.md §11: "Per API key: 600 req/min, or the 126 * key's rateLimit if set"). Distinct from RATE_LIMIT_API (the generic 127 * per-credential window for users/IPs). The key's own `rateLimit` always 128 * wins (set at key creation). 129 */ 130 RATE_LIMIT_API_KEY: z.coerce.number().int().positive().default(600), 131 /** 132 * Google reCAPTCHA siteverify secret (Phase 10 TASK-0107: the public form 133 * page carries the widget slot; while empty the API's verification is the 134 * documented STUB — it skips with a WARN log). Real verification lands in 135 * Phase 13+ against this key. 136 */ 137 RECAPTCHA_SECRET: z.string().default(""), 138 /** 139 * Test-only debug events token (Phase 10 TASK-0109): `GET 140 * /api/v1/debug/events` reads the Redis event stream so the E2E suite can 141 * assert domain events end-to-end. The route 404s unless NODE_ENV=test AND 142 * requires X-Debug-Token = this value; empty (default, incl. production) 143 * = the endpoint is closed (an empty token never matches). 144 */ 145 TEST_DEBUG_TOKEN: z.string().default(""), 146 /** 147 * Shared service token for the internal endpoints (`/internal/*` — TASK-0092 148 * slice 2). The realtime gateway authenticates to the api's internal block 149 * endpoints (hydration read + the flush's CAS persist) with this bearer 150 * token. Empty in dev (the internal endpoints are then CLOSED — the 151 * gateway's HTTP store degrades to the in-memory seam, exactly like 152 * `REALTIME_DB=0`); REQUIRED to open them, so the default is fail-closed 153 * (the task file's recorded decision; the public surface is never opened 154 * by accident). 155 */ 156 INTERNAL_SERVICE_TOKEN: z.string().default(""), 157 /** 158 * Test-only seam (TASK-0095 S9): when set to `"1"`, the internal surface 159 * opens `POST /internal/workspaces/:workspaceId/members` — add a registered 160 * user to a workspace directly (no invite mail). It is guarded by the SAME 161 * fail-closed `InternalServiceGuard` (`INTERNAL_SERVICE_TOKEN`) as the rest 162 * of `/internal/*`, so it is closed unless BOTH `INTERNAL_SERVICE_TOKEN` is 163 * configured AND this flag is enabled. Production never sets either; the 164 * only consumers are the integration suite (in-process app) and the CI e2e 165 * job. Recorded in docs/tasks/phase-07-collaboration.md (TASK-0095 S9). 166 */ 167 INTERNAL_TEST_SEAM: z.string().default("") 168}); 169 170export type Env = z.infer<typeof EnvSchema>; 171 172let cached: Env | undefined; 173 174/** Singleton, lazily validated environment. */ 175export function loadEnv(overrides: NodeJS.ProcessEnv = process.env): Env { 176 if (cached) return cached; 177 const parsed = EnvSchema.safeParse(overrides); 178 if (!parsed.success) { 179 // Fail fast with a readable report — booting with a bad env is a deploy error. 180 const msg = parsed.error.issues 181 .map((i) => ` ${i.path.join(".")}: ${i.message}`) 182 .join("\n"); 183 throw new Error(`Invalid environment:\n${msg}`); 184 } 185 cached = parsed.data; 186 return cached; 187} 188 189export function resetEnvCache(): void { 190 cached = undefined; 191} 192