Barazo AppView backend
barazo.forum
1import { z } from 'zod/v4'
2
3const portSchema = z
4 .string()
5 .default('3000')
6 .transform((val) => Number(val))
7 .pipe(z.number().int().min(1).max(65535))
8
9const intFromString = (defaultVal: string) =>
10 z
11 .string()
12 .default(defaultVal)
13 .transform((val) => Number(val))
14 .pipe(z.number().int().min(0))
15
16const positiveIntFromString = (defaultVal: string) =>
17 z
18 .string()
19 .default(defaultVal)
20 .transform((val) => Number(val))
21 .pipe(z.number().int().positive())
22
23const baseEnvSchema = z.object({
24 // Required
25 DATABASE_URL: z.url(),
26 VALKEY_URL: z.url(),
27 TAP_URL: z.url(),
28 TAP_ADMIN_PASSWORD: z.string().min(1),
29
30 // Server
31 HOST: z.string().default('0.0.0.0'),
32 PORT: portSchema,
33 LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
34
35 // CORS
36 CORS_ORIGINS: z.string().default('http://localhost:3001'),
37
38 // Community
39 COMMUNITY_MODE: z.enum(['single', 'multi']).default('single'),
40 HOSTING_MODE: z.enum(['saas', 'selfhosted']).default('selfhosted'),
41 COMMUNITY_DID: z.string().optional(),
42 COMMUNITY_NAME: z.string().default('Barazo Community'),
43
44 // Rate Limiting (requests per minute)
45 RATE_LIMIT_AUTH: intFromString('10'),
46 RATE_LIMIT_WRITE: intFromString('10'),
47 RATE_LIMIT_READ_ANON: intFromString('100'),
48 RATE_LIMIT_READ_AUTH: intFromString('300'),
49
50 // Encryption (KEK for sensitive data at rest)
51 AI_ENCRYPTION_KEY: z.string().min(32),
52
53 // OAuth
54 OAUTH_CLIENT_ID: z.string().min(1),
55 OAUTH_REDIRECT_URI: z.string().min(1),
56 SESSION_SECRET: z.string().min(32),
57 OAUTH_SESSION_TTL: positiveIntFromString('604800'),
58 OAUTH_ACCESS_TOKEN_TTL: positiveIntFromString('900'),
59
60 // Monitoring (GlitchTip - Sentry SDK compatible)
61 GLITCHTIP_DSN: z.string().optional(),
62
63 // Optional: semantic search
64 EMBEDDING_URL: z.string().optional(),
65 AI_EMBEDDING_DIMENSIONS: z
66 .string()
67 .default('768')
68 .transform((val) => Number(val))
69 .pipe(z.number().int().min(384).max(1536)),
70
71 // Cross-posting
72 FEATURE_CROSSPOST_BLUESKY: z
73 .enum(['true', 'false'])
74 .default('true')
75 .transform((v) => v === 'true'),
76 FEATURE_CROSSPOST_FRONTPAGE: z
77 .enum(['true', 'false'])
78 .default('false')
79 .transform((v) => v === 'true'),
80 PUBLIC_URL: z.string().default('http://localhost:3001'),
81
82 // Multi mode: operator DIDs (comma-separated)
83 OPERATOR_DIDS: z
84 .string()
85 .default('')
86 .transform((v) =>
87 v
88 .split(',')
89 .map((s) => s.trim())
90 .filter((s) => s.length > 0)
91 ),
92
93 // Uploads
94 UPLOAD_DIR: z.string().default('./uploads'),
95 UPLOAD_MAX_SIZE_BYTES: z.coerce.number().default(5_242_880), // 5MB
96 UPLOAD_BASE_URL: z.string().optional(),
97
98 // Ozone labeler (opt-in)
99 OZONE_LABELER_URL: z.string().default('https://mod.bsky.app'),
100})
101
102export const envSchema = baseEnvSchema.refine(
103 (data) =>
104 data.COMMUNITY_MODE !== 'single' || (data.COMMUNITY_DID && data.COMMUNITY_DID.length > 0),
105 {
106 message: 'COMMUNITY_DID is required when COMMUNITY_MODE is "single"',
107 path: ['COMMUNITY_DID'],
108 }
109)
110
111export type Env = z.infer<typeof envSchema>
112
113/**
114 * Get the community DID, throwing if not set.
115 * Safe to call after env validation -- single mode requires COMMUNITY_DID at startup.
116 */
117export function getCommunityDid(env: Env): string {
118 if (!env.COMMUNITY_DID) {
119 throw new Error('COMMUNITY_DID is required in single mode but not set')
120 }
121 return env.COMMUNITY_DID
122}
123
124export function parseEnv(env: Record<string, unknown>): Env {
125 const result = envSchema.safeParse(env)
126 if (!result.success) {
127 const formatted = z.prettifyError(result.error)
128 throw new Error(`Invalid environment configuration:\n${formatted}`)
129 }
130 return result.data
131}