env.ts314 lines · main
| 1 | import { loadEnv } from '@briven/shared'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | /** |
| 5 | * Control-plane env schema. Every var carries the `BRIVEN_` prefix per |
| 6 | * CLAUDE.md §4. Missing required vars fail the process at boot. |
| 7 | * |
| 8 | * Vars that Phase 1 doesn't yet need are marked `.optional()`; they become |
| 9 | * required as the services that consume them come online. |
| 10 | */ |
| 11 | const envSchema = z.object({ |
| 12 | BRIVEN_ENV: z.enum(['development', 'staging', 'production']).default('development'), |
| 13 | BRIVEN_API_PORT: z.coerce.number().int().positive().default(3001), |
| 14 | BRIVEN_API_ORIGIN: z.string().url().default('http://localhost:3001'), |
| 15 | BRIVEN_LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), |
| 16 | |
| 17 | // Control-plane meta-DB — required once Phase 1 week 1 services are wired. |
| 18 | BRIVEN_DATABASE_URL: z.string().url().optional(), |
| 19 | |
| 20 | // Data-plane: shared DoltGres (Postgres-wire git-for-data) cluster where |
| 21 | // each project gets its own DATABASE (`proj_<id>`) — not a schema — so it |
| 22 | // has an independent commit history / branch namespace. Reached via |
| 23 | // postgres.js. CLAUDE.md §3.4 — database-per-tenant up to Team tier, then |
| 24 | // dedicated cluster per tenant. Phase 1 has one cluster. |
| 25 | BRIVEN_DATA_PLANE_URL: z.string().url().optional(), |
| 26 | |
| 27 | // Redis — sessions, queues. Optional until auth lands. |
| 28 | BRIVEN_REDIS_URL: z.string().url().optional(), |
| 29 | |
| 30 | // Auth + JWT signing. Optional until Better Auth lands in Phase 1. |
| 31 | BRIVEN_BETTER_AUTH_SECRET: z.string().min(32).optional(), |
| 32 | BRIVEN_JWT_SIGNING_KEY: z.string().min(32).optional(), |
| 33 | |
| 34 | // Pepper for audit-log IP hashing. Separate from BETTER_AUTH_SECRET so |
| 35 | // a leak of the audit-log column (or someone with shell on the API box) |
| 36 | // can't trivially de-anonymise IPs by knowing the auth secret. Required |
| 37 | // in non-development; in dev a per-process ephemeral value is used. |
| 38 | BRIVEN_AUDIT_IP_PEPPER: z.string().min(32).optional(), |
| 39 | |
| 40 | // Encryption key for customer secrets at rest (AES-256). |
| 41 | BRIVEN_ENCRYPTION_KEY: z.string().min(32).optional(), |
| 42 | |
| 43 | // Per-tenant secret store master keys (ARCHITECTURE.md §4). 32-byte hex. |
| 44 | // Each service has its own key so a leak of one cannot decrypt the other. |
| 45 | BRIVEN_AUTH_MASTER_KEY: z |
| 46 | .string() |
| 47 | .regex(/^[0-9a-f]{64}$/i) |
| 48 | .optional(), |
| 49 | BRIVEN_PAY_MASTER_KEY: z |
| 50 | .string() |
| 51 | .regex(/^[0-9a-f]{64}$/i) |
| 52 | .optional(), |
| 53 | // OLD Briven Auth (Better Auth multi-tenant customer product) — RETIRED / blank. |
| 54 | // Must stay false until SuperTokens-on-Doltgres product is live-OK. |
| 55 | BRIVEN_AUTH_ENABLED: z |
| 56 | .enum(['true', 'false']) |
| 57 | .default('false') |
| 58 | .transform((v) => v === 'true'), |
| 59 | |
| 60 | // briven-engine (Option B) — Phase 1 status shell on Doltgres. |
| 61 | // true = ensure DB/schema + /v1/auth-core/info|ready. App login still closed. |
| 62 | BRIVEN_AUTH_CORE_ENABLED: z |
| 63 | .enum(['true', 'false']) |
| 64 | .default('true') |
| 65 | .transform((v) => v === 'true'), |
| 66 | /** Doltgres URL for Auth vault DB (must host=doltgres or local doltgres port). */ |
| 67 | BRIVEN_ENGINE_DATABASE_URL: z.string().optional(), |
| 68 | // Legacy unused HTTP Core URI — ignored by Doltgres-native engine. |
| 69 | BRIVEN_ENGINE_CONNECTION_URI: z.string().optional(), |
| 70 | BRIVEN_ENGINE_API_KEY: z.string().optional(), |
| 71 | BRIVEN_ENGINE_LICENSE_KEY: z.string().optional(), |
| 72 | BRIVEN_SUPERTOKENS_CONNECTION_URI: z.string().optional(), |
| 73 | BRIVEN_SUPERTOKENS_API_KEY: z.string().optional(), |
| 74 | |
| 75 | // Polar.sh billing — Phase 3. |
| 76 | // Sandbox vs production: set BRIVEN_POLAR_API_BASE to |
| 77 | // https://sandbox-api.polar.sh during dev |
| 78 | // https://api.polar.sh once the production product exists |
| 79 | // Matching access tokens + webhook secrets come from the matching env. |
| 80 | BRIVEN_POLAR_API_BASE: z.string().url().default('https://api.polar.sh'), |
| 81 | BRIVEN_POLAR_ACCESS_TOKEN: z.string().optional(), |
| 82 | BRIVEN_POLAR_WEBHOOK_SECRET: z.string().optional(), |
| 83 | // Polar product UUIDs per tier. Checkout + webhook → tier mapping both |
| 84 | // read these. Until they're set, `/v1/billing/plans` reports no plans |
| 85 | // and the settings page keeps the upgrade UI disabled. |
| 86 | BRIVEN_POLAR_PRO_PRODUCT_ID: z.string().optional(), |
| 87 | BRIVEN_POLAR_TEAM_PRODUCT_ID: z.string().optional(), |
| 88 | |
| 89 | // Polar meter UUIDs per metric — read by the meter-push worker. Until |
| 90 | // set, usage_events rows are marked `skipped` and the operator can |
| 91 | // verify what we'd push via the admin usage page. |
| 92 | BRIVEN_POLAR_METER_INVOCATIONS_ID: z.string().optional(), |
| 93 | BRIVEN_POLAR_METER_STORAGE_ID: z.string().optional(), |
| 94 | BRIVEN_POLAR_METER_CONNECTION_ID: z.string().optional(), |
| 95 | // briven auth MAU meter — distinct users active in the trailing 30 days, |
| 96 | // pushed by polar-meter-push.ts when usage_events.metric='auth_mau'. |
| 97 | // Unset means skip; rows mark 'skipped' and the operator can flip them |
| 98 | // back to 'pending' once the meter is provisioned. |
| 99 | BRIVEN_POLAR_METER_AUTH_MAU_ID: z.string().optional(), |
| 100 | // Phase 5.7 — SSO connection count + sign-in volume meters (optional). |
| 101 | BRIVEN_POLAR_METER_AUTH_SSO_CONNECTIONS_ID: z.string().optional(), |
| 102 | BRIVEN_POLAR_METER_AUTH_SSO_SIGNINS_ID: z.string().optional(), |
| 103 | |
| 104 | // mittera.eu transactional email. Outbound sends authenticate with |
| 105 | // the API key; inbound webhooks (delivery / bounce / complaint) are |
| 106 | // verified with the webhook secret. URL + API key must both be set |
| 107 | // for mail to go out; missing either falls back to stdout-only for |
| 108 | // first-user bootstrap. Webhook secret is only required if mittera |
| 109 | // posts events back — without it the receiver returns 503. |
| 110 | BRIVEN_MITTERA_API_URL: z.string().url().optional(), |
| 111 | BRIVEN_MITTERA_API_KEY: z.string().optional(), |
| 112 | BRIVEN_MITTERA_WEBHOOK_SECRET: z.string().optional(), |
| 113 | |
| 114 | // Real SMTP transport. mittera.eu accepts sends (200) but does NOT |
| 115 | // deliver (proven by 44 `.sent` audit rows and ZERO `.delivered` |
| 116 | // webhooks), so once a real provider (Resend / Mailgun / Postmark / SES |
| 117 | // — all speak standard SMTP) is wired here, SMTP becomes the PRIMARY |
| 118 | // sender and mittera drops to a fallback. SMTP is "configured" only when |
| 119 | // HOST + USER + PASS are all non-empty; PORT defaults to 587 (STARTTLS), |
| 120 | // and secure TLS is used automatically on 465. FROM overrides the |
| 121 | // fromAddress() default when set (e.g. "Briven <noreply@briven.tech>"). |
| 122 | BRIVEN_SMTP_HOST: z.string().optional(), |
| 123 | BRIVEN_SMTP_PORT: z.coerce.number().int().positive().default(587), |
| 124 | BRIVEN_SMTP_USER: z.string().optional(), |
| 125 | BRIVEN_SMTP_PASS: z.string().optional(), |
| 126 | BRIVEN_SMTP_FROM: z.string().optional(), |
| 127 | |
| 128 | // MinIO — object storage. |
| 129 | // _ENDPOINT server-side (internal docker network OK). |
| 130 | // _PUBLIC_ENDPOINT what the browser sees in presigned URLs. HTTPS |
| 131 | // in prod. Falls back to _ENDPOINT if unset (dev). |
| 132 | // _BUCKET / _REGION defaults: "briven" / "us-east-1". |
| 133 | BRIVEN_MINIO_ENDPOINT: z.string().url().optional(), |
| 134 | BRIVEN_MINIO_PUBLIC_ENDPOINT: z.string().url().optional(), |
| 135 | BRIVEN_MINIO_ACCESS_KEY: z.string().optional(), |
| 136 | BRIVEN_MINIO_SECRET_KEY: z.string().optional(), |
| 137 | BRIVEN_MINIO_BUCKET: z.string().optional(), |
| 138 | BRIVEN_MINIO_REGION: z.string().optional(), |
| 139 | |
| 140 | // imgproxy — on-the-fly image transforms (M4). Signed URLs let the browser |
| 141 | // request a resized variant of a PUBLIC file; imgproxy fetches the source |
| 142 | // from the /media host, resizes, and returns it. All three must be set for |
| 143 | // the feature to be available — when any is unset the transform endpoint |
| 144 | // returns 503 not_configured (fail-safe; the imgproxy container is wired at |
| 145 | // deploy). _KEY / _SALT are hex, per imgproxy's URL-signing scheme. |
| 146 | BRIVEN_IMGPROXY_ENDPOINT: z.string().url().optional(), |
| 147 | BRIVEN_IMGPROXY_KEY: z.string().optional(), |
| 148 | BRIVEN_IMGPROXY_SALT: z.string().optional(), |
| 149 | |
| 150 | // Dokploy — infra provisioning (Phase 2+). |
| 151 | BRIVEN_DOKPLOY_API_URL: z.string().url().optional(), |
| 152 | BRIVEN_DOKPLOY_API_TOKEN: z.string().optional(), |
| 153 | |
| 154 | // Google OAuth — used by Better Auth for the "sign in with google" flow. |
| 155 | BRIVEN_GOOGLE_CLIENT_ID: z.string().optional(), |
| 156 | BRIVEN_GOOGLE_CLIENT_SECRET: z.string().optional(), |
| 157 | |
| 158 | // GitHub OAuth — paired with Google for the second mainstream provider. |
| 159 | BRIVEN_GITHUB_CLIENT_ID: z.string().optional(), |
| 160 | BRIVEN_GITHUB_CLIENT_SECRET: z.string().optional(), |
| 161 | |
| 162 | // Discord OAuth — useful for gaming / community-oriented apps. Falls |
| 163 | // back to "not available" in the signin UI when unset. |
| 164 | BRIVEN_DISCORD_CLIENT_ID: z.string().optional(), |
| 165 | BRIVEN_DISCORD_CLIENT_SECRET: z.string().optional(), |
| 166 | // Public invite URL for the beta Discord server. When set, the |
| 167 | // dashboard and admin status panel surface a "join the alpha discord" |
| 168 | // link. Operator sets this once the server is created (see |
| 169 | // docs/runbooks/discord-setup.md). |
| 170 | BRIVEN_DISCORD_INVITE_URL: z.string().url().optional(), |
| 171 | |
| 172 | // Inbox the operator notification email is sent to whenever a |
| 173 | // customer submits /dashboard/projects/new/migrate/<source>. |
| 174 | // Default points at flndrn.com (the parent legal entity that runs |
| 175 | // briven + ISY + Mavi from a single support queue at admin.flndrn.com). |
| 176 | // Override if you want product-specific routing later. |
| 177 | BRIVEN_MIGRATIONS_INBOX: z.string().email().default('migrations@flndrn.com'), |
| 178 | |
| 179 | // Konnos OAuth — Forgejo at code.konnos.org. Better Auth's generic OAuth |
| 180 | // plugin lets us reuse the same callback shape for any OAuth2/OIDC |
| 181 | // provider; the Forgejo endpoints (authorize / token / userinfo) are |
| 182 | // pinned below. |
| 183 | BRIVEN_KONNOS_CLIENT_ID: z.string().optional(), |
| 184 | BRIVEN_KONNOS_CLIENT_SECRET: z.string().optional(), |
| 185 | BRIVEN_KONNOS_ISSUER: z.string().url().default('https://code.konnos.org'), |
| 186 | |
| 187 | // Public domain (registrable, no scheme). Drives cross-subdomain cookie |
| 188 | // scope so the session cookie set on api.<domain> is readable by the |
| 189 | // dashboard at <domain> (and docs/realtime). Required in production. |
| 190 | BRIVEN_DOMAIN: z.string().optional(), |
| 191 | |
| 192 | // Web origin for email link callbacks. |
| 193 | BRIVEN_WEB_ORIGIN: z.string().url().default('http://localhost:3000'), |
| 194 | BRIVEN_STUDIO_ORIGIN: z.string().url().default('http://localhost:8082'), |
| 195 | // Dedicated admin cockpit host (admin.<domain>) — must be CORS-allowed or |
| 196 | // every client-side fetch from the cockpit dies with "Failed to fetch". |
| 197 | BRIVEN_ADMIN_ORIGIN: z.string().url().optional(), |
| 198 | // Comma-separated allowlist of emails that may EVER be platform admin. |
| 199 | // When set, the users.isAdmin DB flag alone no longer grants admin — |
| 200 | // see lib/superadmin.ts. Unset = DB flag decides (local dev). |
| 201 | BRIVEN_SUPERADMIN_EMAILS: z.string().optional(), |
| 202 | |
| 203 | // Comma-separated list of origins Better Auth will accept as `callbackURL`. |
| 204 | // Must include every public hostname that serves the dashboard. |
| 205 | BRIVEN_TRUSTED_ORIGINS: z.string().default('http://localhost:3000'), |
| 206 | |
| 207 | // Runtime — apps/runtime's invoke endpoint. The shared secret must match |
| 208 | // BRIVEN_RUNTIME_SHARED_SECRET on the runtime host. |
| 209 | BRIVEN_RUNTIME_URL: z.string().url().default('http://localhost:3003'), |
| 210 | BRIVEN_RUNTIME_SHARED_SECRET: z.string().min(32).optional(), |
| 211 | |
| 212 | // Realtime — apps/realtime's /metrics endpoint. The hourly usage |
| 213 | // aggregator scrapes briven_realtime_connection_seconds_total{project} |
| 214 | // from here to compute per-project per-hour connection-second deltas. |
| 215 | // Optional — when unset the aggregator skips connection_seconds and |
| 216 | // only writes invocations + storage_bytes. |
| 217 | BRIVEN_REALTIME_URL: z.string().url().default('http://localhost:3004'), |
| 218 | |
| 219 | // Prometheus base URL for the Phase 4 observability stack. Read by |
| 220 | // services/platform-health.ts (instant host metrics) and |
| 221 | // routes/admin-timeseries.ts (24h range queries for the cockpit charts). |
| 222 | // Optional — when unset those surfaces return null and the cockpit shows |
| 223 | // "monitoring not connected"; we never fabricate a number. |
| 224 | BRIVEN_PROMETHEUS_URL: z.string().url().optional(), |
| 225 | |
| 226 | // GeoIP — optional path to a MaxMind GeoLite2-City.mmdb file. When unset |
| 227 | // or unreadable, IP → city lookups return null and callers show a dash. |
| 228 | // Refresh the DB monthly via the free MaxMind account download portal. |
| 229 | BRIVEN_GEOIP_DB_PATH: z.string().optional(), |
| 230 | |
| 231 | // Ollama base URL for the AI features (schema gen / function gen / |
| 232 | // explain / docs assistant). Points at the production proxy at |
| 233 | // ai.flndrn.com today; switches to a local DGX hostname once the |
| 234 | // hardware lands. When unset, /v1/projects/:id/ai/* endpoints return |
| 235 | // 503 not_configured and the dashboard hides the AI affordances. |
| 236 | BRIVEN_OLLAMA_URL: z.string().url().optional(), |
| 237 | // Optional bearer token for the Ollama backend. The production |
| 238 | // proxy at ai.flndrn.com is gated by API-key auth; a local Ollama |
| 239 | // on the DGX over a private network doesn't need this (leave unset). |
| 240 | // When set we send `Authorization: Bearer <key>` on every /api/generate |
| 241 | // request. NEVER log this value. |
| 242 | BRIVEN_OLLAMA_API_KEY: z.string().optional(), |
| 243 | // Default model id — must match what Ollama has pulled / what the |
| 244 | // proxy exposes. Each AI feature can override via |
| 245 | // BRIVEN_OLLAMA_MODEL_<FEATURE> below; unset means "fall back to this |
| 246 | // default". See docs/AI.md for the recommended per-feature matrix. |
| 247 | BRIVEN_OLLAMA_MODEL: z.string().default('qwen2.5-coder:32b'), |
| 248 | BRIVEN_OLLAMA_MODEL_SCHEMA: z.string().optional(), |
| 249 | BRIVEN_OLLAMA_MODEL_FUNCTION: z.string().optional(), |
| 250 | BRIVEN_OLLAMA_MODEL_EXPLAIN: z.string().optional(), |
| 251 | BRIVEN_OLLAMA_MODEL_DOCS: z.string().optional(), |
| 252 | |
| 253 | // admin.flndrn.com dashboard API key. Single shared bearer secret the |
| 254 | // cross-product operator console at admin.flndrn.com sends on every |
| 255 | // probe of briven's /api/admin/* endpoints. Must equal the key |
| 256 | // registered for briven in admin.flndrn.com. Optional: when unset, the |
| 257 | // /api/admin/* routes return 503 admin_not_configured (fail-safe — the |
| 258 | // dashboard shows "not configured" rather than the process crashing). |
| 259 | BRIVEN_ADMIN_API_KEY: z.string().optional(), |
| 260 | |
| 261 | // Public-signups gate. Default false — invite-only beta. Flip to `true` |
| 262 | // when public open-signups land per BUILD_PLAN Phase 4. Affects every |
| 263 | // first-time auth path (email+password, magic link, Google OAuth); |
| 264 | // existing users always retain sign-IN regardless of this flag. |
| 265 | BRIVEN_OPEN_SIGNUPS: z |
| 266 | .string() |
| 267 | .optional() |
| 268 | .transform((v) => v === 'true' || v === '1'), |
| 269 | |
| 270 | // Cloudflare Turnstile — invisible CAPTCHA replacement for bot protection |
| 271 | // on briven auth sign-up / sign-in flows. The site key is per-tenant config; |
| 272 | // this secret verifies tokens server-side. |
| 273 | BRIVEN_TURNSTILE_SECRET_KEY: z.string().optional(), |
| 274 | }); |
| 275 | |
| 276 | export type Env = z.infer<typeof envSchema>; |
| 277 | |
| 278 | export const env = loadEnv(envSchema); |
| 279 | |
| 280 | /** |
| 281 | * Cross-field invariants that zod can't easily express via `.refine()` |
| 282 | * because they depend on the resolved BRIVEN_ENV. Run after loadEnv so the |
| 283 | * process fails loudly at boot when production config is wrong. |
| 284 | */ |
| 285 | if (env.BRIVEN_ENV !== 'development') { |
| 286 | if (!env.BRIVEN_API_ORIGIN.startsWith('https://')) { |
| 287 | throw new Error( |
| 288 | `BRIVEN_API_ORIGIN must be HTTPS outside development (got: ${env.BRIVEN_API_ORIGIN})`, |
| 289 | ); |
| 290 | } |
| 291 | if (!env.BRIVEN_WEB_ORIGIN.startsWith('https://')) { |
| 292 | throw new Error( |
| 293 | `BRIVEN_WEB_ORIGIN must be HTTPS outside development (got: ${env.BRIVEN_WEB_ORIGIN})`, |
| 294 | ); |
| 295 | } |
| 296 | // why: BRIVEN_ENCRYPTION_KEY decrypts customer env vars at rest. If |
| 297 | // unset, services/project-env.ts fails-closed at request time — but a |
| 298 | // deploy that forgot the key would only surface the misconfiguration |
| 299 | // when the first customer reads encrypted env. Fail at boot instead. |
| 300 | if (!env.BRIVEN_ENCRYPTION_KEY) { |
| 301 | throw new Error( |
| 302 | 'BRIVEN_ENCRYPTION_KEY must be set outside development (AES-256 KEK for customer env vars at rest)', |
| 303 | ); |
| 304 | } |
| 305 | // why: BRIVEN_DATA_PLANE_URL is marked .optional() so local dev can boot |
| 306 | // without a data-plane, but every customer-facing operation (project |
| 307 | // create, schema apply, studio reads/writes) requires it. Failing here |
| 308 | // surfaces the misconfiguration at boot instead of on the first request. |
| 309 | if (!env.BRIVEN_DATA_PLANE_URL) { |
| 310 | throw new Error( |
| 311 | 'BRIVEN_DATA_PLANE_URL must be set outside development (per-project schemas live in this cluster)', |
| 312 | ); |
| 313 | } |
| 314 | } |