env.ts314 lines · main
1import { loadEnv } from '@briven/shared';
2import { 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 */
11const 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
276export type Env = z.infer<typeof envSchema>;
277
278export 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 */
285if (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}