-
Notifications
You must be signed in to change notification settings - Fork 18
Expand file tree
/
Copy pathenv.ts
More file actions
117 lines (106 loc) · 5.1 KB
/
env.ts
File metadata and controls
117 lines (106 loc) · 5.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import { z } from 'zod'
/**
* Preprocessor that normalizes empty strings to undefined.
* Railway and some platforms may set env vars to "" instead of leaving them unset.
* This ensures `.default()` and `.optional()` work as expected.
*/
const emptyToUndefined = z.preprocess(
(val) => (typeof val === 'string' && val.trim() === '' ? undefined : val),
z.string(),
)
/**
* Detect whether the current Railway environment is a PR/preview environment.
* Production and long-lived environments must provide explicit BETTER_AUTH_URL.
*/
export function isRailwayPreviewEnvironment(environmentName?: string): boolean {
const name = environmentName?.toLowerCase().trim() ?? ''
if (!name) return false
// Never treat production as preview.
if (name === 'production' || name === 'prod') return false
return (
name.startsWith('pr')
||
/^pr(?:-|\d)/.test(name)
|| name.includes('pr-')
|| name.includes('pr ')
|| name.includes('pull request')
|| name.includes('pull-request')
|| name.includes('preview')
)
}
const envSchema = z
.object({
DATABASE_URL: z.url(),
BETTER_AUTH_SECRET: emptyToUndefined.pipe(z.string().min(32, 'BETTER_AUTH_SECRET must be at least 32 characters')),
BETTER_AUTH_URL: emptyToUndefined.pipe(z.url()).optional(),
/** Railway environment metadata for PR/preview detection. */
RAILWAY_ENVIRONMENT_NAME: emptyToUndefined.optional(),
/** PR number provided by Railway for GitHub-triggered deployments. */
RAILWAY_GIT_PR_NUMBER: emptyToUndefined.pipe(z.string().regex(/^\d+$/, 'RAILWAY_GIT_PR_NUMBER must be numeric')).optional(),
/** Public domain generated by Railway for the current deployment. */
RAILWAY_PUBLIC_DOMAIN: emptyToUndefined.optional(),
S3_ENDPOINT: z.url(),
S3_ACCESS_KEY: emptyToUndefined.pipe(z.string().min(1)),
S3_SECRET_KEY: emptyToUndefined.pipe(z.string().min(1)),
S3_BUCKET: emptyToUndefined.pipe(z.string().min(1)),
S3_REGION: emptyToUndefined.pipe(z.string().min(1)).optional().default('us-east-1'),
/** Use path-style S3 URLs. Required for MinIO (local dev), must be `false` for Railway Buckets / AWS S3. */
S3_FORCE_PATH_STYLE: z.preprocess(
(val) => (typeof val === 'string' && val.trim() === '' ? undefined : val === 'true' || val === undefined),
z.boolean().default(true),
),
/** IP address of the trusted reverse proxy (e.g., Railway, Cloudflare). When set, X-Forwarded-For is trusted for rate limiting. */
TRUSTED_PROXY_IP: z.string().min(1).optional(),
/** Slug of the demo organization. When set, write operations are blocked for this org. */
DEMO_ORG_SLUG: emptyToUndefined.optional(),
/** Fine-grained GitHub PAT with Issues:write scope. When set (along with GITHUB_FEEDBACK_REPO), enables in-app feedback. */
GITHUB_FEEDBACK_TOKEN: emptyToUndefined.pipe(z.string().min(1)).optional(),
/** GitHub repo in "owner/repo" format for feedback issues. */
GITHUB_FEEDBACK_REPO: emptyToUndefined.pipe(z.string().regex(/^[^/]+\/[^/]+$/, 'Must be in "owner/repo" format')).optional(),
})
.superRefine((data, ctx) => {
const isPreview = isRailwayPreviewEnvironment(data.RAILWAY_ENVIRONMENT_NAME)
if (!isPreview && !data.BETTER_AUTH_URL) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['BETTER_AUTH_URL'],
message: 'BETTER_AUTH_URL is required outside Railway PR/preview environments',
})
}
})
/**
* Validated environment variables. Uses lazy initialization so the schema
* is only parsed on first access at runtime — not at import time. This
* prevents build-time prerendering from failing when env vars aren't
* available (e.g., Railway injects variables only at deploy time, not
* during the build phase).
*/
export const env = new Proxy({} as z.infer<typeof envSchema>, {
get(_, prop: string | symbol) {
// During build-time prerendering, env vars aren't available.
// Return safe defaults so the prerenderer can boot without crashing.
if (import.meta.prerender) {
return ''
}
if (typeof prop === 'symbol') return undefined
// Parse once on first access, then cache for all subsequent reads
if (!(globalThis as Record<string, unknown>).__env) {
const result = envSchema.safeParse(process.env)
if (!result.success) {
const missing = result.error.issues
.map(i => ` - ${i.path.join('.')}: ${i.message}`)
.join('\n')
console.error(
`\n[Applirank] ❌ Missing or invalid environment variables:\n${missing}\n\n` +
`Ensure these variables are set in your Railway service (Settings → Variables).\n` +
`Required: DATABASE_URL, BETTER_AUTH_SECRET, S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET\n` +
`Required outside Railway PR/preview environments: BETTER_AUTH_URL\n` +
`Optional: S3_REGION (default: us-east-1), S3_FORCE_PATH_STYLE (default: true), TRUSTED_PROXY_IP, DEMO_ORG_SLUG\n`,
)
throw result.error
}
;(globalThis as Record<string, unknown>).__env = result.data
}
return ((globalThis as Record<string, unknown>).__env as Record<string, unknown>)[prop]
},
})