fix(envvars): t3-env standardization (#606)

* chore: use t3-env as source of truth

* chore: update mock env for failing tests
This commit is contained in:
Aditya Tripathi
2025-07-06 23:08:17 +05:30
committed by Vikhyath Mondreti
parent 231bfb9add
commit e22f0123a3
20 changed files with 69 additions and 54 deletions

View File

@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { verifyInternalToken } from '@/lib/auth/internal'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger'
import { getUserEntityPermissions, hasAdminPermission } from '@/lib/permissions/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
@@ -215,7 +216,7 @@ export async function DELETE(
// This prevents "Block not found" errors when collaborative updates try to process
// after the workflow has been deleted
try {
const socketUrl = process.env.SOCKET_SERVER_URL || 'http://localhost:3002'
const socketUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
const socketResponse = await fetch(`${socketUrl}/api/workflow-deleted`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -30,6 +30,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger'
import { getBaseDomain } from '@/lib/urls/utils'
import { cn } from '@/lib/utils'
@@ -54,7 +55,7 @@ interface ChatDeployProps {
type AuthType = 'public' | 'password' | 'email'
const getDomainSuffix = (() => {
const suffix = process.env.NODE_ENV === 'development' ? `.${getBaseDomain()}` : '.simstudio.ai'
const suffix = env.NODE_ENV === 'development' ? `.${getBaseDomain()}` : '.simstudio.ai'
return () => suffix
})()

View File

@@ -7,6 +7,7 @@ import { useParams, usePathname, useRouter } from 'next/navigation'
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger'
import {
getKeyboardShortcutText,
@@ -27,7 +28,7 @@ import { WorkspaceHeader } from './components/workspace-header/workspace-header'
const logger = createLogger('Sidebar')
const IS_DEV = process.env.NODE_ENV === 'development'
const IS_DEV = env.NODE_ENV === 'development'
export function Sidebar() {
useGlobalShortcuts()

View File

@@ -1,11 +1,12 @@
import { DocumentIcon } from '@/components/icons'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger'
import type { FileParserOutput } from '@/tools/file/types'
import type { BlockConfig, SubBlockConfig, SubBlockLayout, SubBlockType } from '../types'
const logger = createLogger('FileBlock')
const shouldEnableURLInput = process.env.NODE_ENV === 'production'
const shouldEnableURLInput = env.NODE_ENV === 'production'
const inputMethodBlock: SubBlockConfig = {
id: 'inputMethod',

View File

@@ -1,8 +1,9 @@
import { MistralIcon } from '@/components/icons'
import { env } from '@/lib/env'
import type { MistralParserOutput } from '@/tools/mistral/types'
import type { BlockConfig, SubBlockConfig, SubBlockLayout, SubBlockType } from '../types'
const shouldEnableFileUpload = process.env.NODE_ENV === 'production'
const shouldEnableFileUpload = env.NODE_ENV === 'production'
const inputMethodBlock: SubBlockConfig = {
id: 'inputMethod',

View File

@@ -11,6 +11,7 @@ import {
Section,
Text,
} from '@react-email/components'
import { env } from '@/lib/env'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
@@ -20,7 +21,7 @@ interface WorkspaceInvitationEmailProps {
invitationLink?: string
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
export const WorkspaceInvitationEmail = ({
workspaceName = 'Workspace',

View File

@@ -11,6 +11,7 @@ import {
} from 'react'
import { useParams } from 'next/navigation'
import { io, type Socket } from 'socket.io-client'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('SocketContext')
@@ -134,7 +135,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
// Generate initial token for socket authentication
const token = await generateSocketToken()
const socketUrl = process.env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3002'
const socketUrl = env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3002'
logger.info('Attempting to connect to Socket.IO server', {
url: socketUrl,

View File

@@ -38,4 +38,4 @@ declare global {
}
export const db = global.database || drizzleClient
if (process.env.NODE_ENV !== 'production') global.database = db
if (env.NODE_ENV !== 'production') global.database = db

View File

@@ -1,13 +1,14 @@
import { stripeClient } from '@better-auth/stripe/client'
import { emailOTPClient, genericOAuthClient, organizationClient } from 'better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'
import { env } from './env'
const clientEnv = {
NEXT_PUBLIC_VERCEL_URL: process.env.NEXT_PUBLIC_VERCEL_URL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NODE_ENV: process.env.NODE_ENV,
VERCEL_ENV: process.env.VERCEL_ENV || '',
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
NEXT_PUBLIC_VERCEL_URL: env.NEXT_PUBLIC_VERCEL_URL,
NEXT_PUBLIC_APP_URL: env.NEXT_PUBLIC_APP_URL,
NODE_ENV: env.NODE_ENV,
VERCEL_ENV: env.VERCEL_ENV || '',
BETTER_AUTH_URL: env.BETTER_AUTH_URL,
}
export function getBaseURL() {

View File

@@ -6,7 +6,11 @@ import {
verifyUnsubscribeToken,
} from './unsubscribe'
vi.stubEnv('BETTER_AUTH_SECRET', 'test-secret-key')
vi.mock('../env', () => ({
env: {
BETTER_AUTH_SECRET: 'test-secret-key',
},
}))
describe('unsubscribe utilities', () => {
const testEmail = 'test@example.com'
@@ -75,10 +79,9 @@ describe('unsubscribe utilities', () => {
it.concurrent('should handle legacy tokens (2 parts) and default to marketing', () => {
// Generate a real legacy token using the actual hashing logic to ensure backward compatibility
const salt = 'abc123'
const secret = 'test-secret-key'
const { createHash } = require('crypto')
const hash = createHash('sha256')
.update(`${testEmail}:${salt}:${process.env.BETTER_AUTH_SECRET}`)
.digest('hex')
const hash = createHash('sha256').update(`${testEmail}:${salt}:${secret}`).digest('hex')
const legacyToken = `${salt}:${hash}`
// This should return valid since we're using the actual legacy format properly

View File

@@ -3,6 +3,7 @@ import { eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { settings, user } from '@/db/schema'
import { env } from '../env'
import type { EmailType } from './mailer'
const logger = createLogger('Unsubscribe')
@@ -20,7 +21,7 @@ export interface EmailPreferences {
export function generateUnsubscribeToken(email: string, emailType = 'marketing'): string {
const salt = randomBytes(16).toString('hex')
const hash = createHash('sha256')
.update(`${email}:${salt}:${emailType}:${process.env.BETTER_AUTH_SECRET}`)
.update(`${email}:${salt}:${emailType}:${env.BETTER_AUTH_SECRET}`)
.digest('hex')
return `${salt}:${hash}:${emailType}`
@@ -41,7 +42,7 @@ export function verifyUnsubscribeToken(
if (parts.length === 2) {
const [salt, expectedHash] = parts
const hash = createHash('sha256')
.update(`${email}:${salt}:${process.env.BETTER_AUTH_SECRET}`)
.update(`${email}:${salt}:${env.BETTER_AUTH_SECRET}`)
.digest('hex')
return { valid: hash === expectedHash, emailType: 'marketing' }
@@ -52,7 +53,7 @@ export function verifyUnsubscribeToken(
if (!salt || !expectedHash || !emailType) return { valid: false }
const hash = createHash('sha256')
.update(`${email}:${salt}:${emailType}:${process.env.BETTER_AUTH_SECRET}`)
.update(`${email}:${salt}:${emailType}:${env.BETTER_AUTH_SECRET}`)
.digest('hex')
return { valid: hash === expectedHash, emailType }

View File

@@ -104,6 +104,8 @@ export const env = createEnv({
SLACK_CLIENT_ID: z.string().optional(),
SLACK_CLIENT_SECRET: z.string().optional(),
SOCKET_SERVER_URL: z.string().url().optional(),
SOCKET_PORT: z.number().optional(),
PORT: z.number().optional(),
},
client: {

View File

@@ -93,10 +93,10 @@ export async function executeCode(
nodeModules: packages,
timeout: null,
// Add environment variables if needed
envVars: Object.entries(process.env).reduce(
envVars: Object.entries(env).reduce(
(acc, [key, value]) => {
if (value !== undefined) {
acc[key] = value
acc[key] = value as string
}
return acc
},

View File

@@ -5,6 +5,7 @@
* It is separate from the user-facing logging system in logging.ts.
*/
import chalk from 'chalk'
import { env } from '../env'
/**
* LogLevel enum defines the severity levels for logging
@@ -55,7 +56,7 @@ const LOG_CONFIG = {
}
// Get current environment
const ENV = (process.env.NODE_ENV || 'development') as keyof typeof LOG_CONFIG
const ENV = (env.NODE_ENV || 'development') as keyof typeof LOG_CONFIG
const config = LOG_CONFIG[ENV] || LOG_CONFIG.development
// Format objects for logging

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { member, subscription, userStats } from '@/db/schema'
import { client } from '../auth-client'
import { env } from '../env'
import { calculateUsageLimit, checkEnterprisePlan, checkProPlan, checkTeamPlan } from './utils'
const logger = createLogger('Subscription')
@@ -172,9 +173,7 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
limit,
})
} else {
limit = process.env.FREE_TIER_COST_LIMIT
? Number.parseFloat(process.env.FREE_TIER_COST_LIMIT)
: 5
limit = env.FREE_TIER_COST_LIMIT || 5
logger.info('Using free tier limit', { userId, limit })
}

View File

@@ -1,18 +1,14 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { calculateUsageLimit, checkEnterprisePlan } from './utils'
const ORIGINAL_ENV = { ...process.env }
beforeAll(() => {
process.env.FREE_TIER_COST_LIMIT = '5'
process.env.PRO_TIER_COST_LIMIT = '20'
process.env.TEAM_TIER_COST_LIMIT = '40'
process.env.ENTERPRISE_TIER_COST_LIMIT = '200'
})
afterAll(() => {
process.env = ORIGINAL_ENV
})
vi.mock('../env', () => ({
env: {
FREE_TIER_COST_LIMIT: 5,
PRO_TIER_COST_LIMIT: 20,
TEAM_TIER_COST_LIMIT: 40,
ENTERPRISE_TIER_COST_LIMIT: 200,
},
}))
describe('Subscription Utilities', () => {
describe('checkEnterprisePlan', () => {

View File

@@ -1,3 +1,5 @@
import { env } from '../env'
export function checkEnterprisePlan(subscription: any): boolean {
return subscription?.plan === 'enterprise' && subscription?.status === 'active'
}
@@ -17,16 +19,16 @@ export function checkTeamPlan(subscription: any): boolean {
*/
export function calculateUsageLimit(subscription: any): number {
if (!subscription || subscription.status !== 'active') {
return Number.parseFloat(process.env.FREE_TIER_COST_LIMIT!)
return env.FREE_TIER_COST_LIMIT || 0
}
const seats = subscription.seats || 1
if (subscription.plan === 'pro') {
return Number.parseFloat(process.env.PRO_TIER_COST_LIMIT!)
return env.PRO_TIER_COST_LIMIT || 0
}
if (subscription.plan === 'team') {
return seats * Number.parseFloat(process.env.TEAM_TIER_COST_LIMIT!)
return seats * (env.TEAM_TIER_COST_LIMIT || 0)
}
if (subscription.plan === 'enterprise') {
const metadata = subscription.metadata || {}
@@ -39,8 +41,8 @@ export function calculateUsageLimit(subscription: any): number {
return Number.parseFloat(metadata.totalAllowance)
}
return seats * Number.parseFloat(process.env.ENTERPRISE_TIER_COST_LIMIT!)
return seats * (env.ENTERPRISE_TIER_COST_LIMIT || 0)
}
return Number.parseFloat(process.env.FREE_TIER_COST_LIMIT!)
return env.FREE_TIER_COST_LIMIT || 0
}

View File

@@ -1,3 +1,5 @@
import { env } from '../env'
/**
* Returns the base URL of the application, respecting environment variables for deployment environments
* @returns The base URL string (e.g., 'http://localhost:3000' or 'https://example.com')
@@ -7,13 +9,13 @@ export function getBaseUrl(): string {
return window.location.origin
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL
const baseUrl = env.NEXT_PUBLIC_APP_URL
if (baseUrl) {
if (baseUrl.startsWith('http://') || baseUrl.startsWith('https://')) {
return baseUrl
}
const isProd = process.env.NODE_ENV === 'production'
const isProd = env.NODE_ENV === 'production'
const protocol = isProd ? 'https://' : 'http://'
return `${protocol}${baseUrl}`
}
@@ -30,11 +32,11 @@ export function getBaseDomain(): string {
const url = new URL(getBaseUrl())
return url.host // host includes port if specified
} catch (_e) {
const fallbackUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const fallbackUrl = env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
try {
return new URL(fallbackUrl).host
} catch {
const isProd = process.env.NODE_ENV === 'production'
const isProd = env.NODE_ENV === 'production'
return isProd ? 'simstudio.ai' : 'localhost:3000'
}
}
@@ -49,7 +51,7 @@ export function getEmailDomain(): string {
const baseDomain = getBaseDomain()
return baseDomain.startsWith('www.') ? baseDomain.substring(4) : baseDomain
} catch (_e) {
const isProd = process.env.NODE_ENV === 'production'
const isProd = env.NODE_ENV === 'production'
return isProd ? 'simstudio.ai' : 'localhost:3000'
}
}

View File

@@ -27,10 +27,10 @@ const nextConfig: NextConfig = {
},
...(env.NODE_ENV === 'development' && {
allowedDevOrigins: [
...(process.env.NEXT_PUBLIC_APP_URL
...(env.NEXT_PUBLIC_APP_URL
? (() => {
try {
return [new URL(process.env.NEXT_PUBLIC_APP_URL).host]
return [new URL(env.NEXT_PUBLIC_APP_URL).host]
} catch {
return []
}
@@ -81,7 +81,7 @@ const nextConfig: NextConfig = {
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
{
key: 'Access-Control-Allow-Origin',
value: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001',
value: env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001',
},
{
key: 'Access-Control-Allow-Methods',
@@ -158,7 +158,7 @@ const nextConfig: NextConfig = {
},
{
key: 'Content-Security-Policy',
value: `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.google.com https://apis.google.com https://*.vercel-scripts.com https://*.vercel-insights.com https://vercel.live https://*.vercel.live https://vercel.com https://*.vercel.app https://vitals.vercel-insights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://*.googleusercontent.com https://*.google.com https://*.atlassian.com https://cdn.discordapp.com https://*.githubusercontent.com; media-src 'self' blob:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ${process.env.NEXT_PUBLIC_APP_URL || ''} ${env.OLLAMA_URL || 'http://localhost:11434'} ${process.env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3002'} ${process.env.NEXT_PUBLIC_SOCKET_URL?.replace('http://', 'ws://').replace('https://', 'wss://') || 'ws://localhost:3002'} https://*.up.railway.app wss://*.up.railway.app https://api.browser-use.com https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.blob.core.windows.net https://*.vercel-insights.com https://vitals.vercel-insights.com https://*.atlassian.com https://vercel.live https://*.vercel.live https://vercel.com https://*.vercel.app wss://*.vercel.app; frame-src https://drive.google.com https://*.google.com; frame-ancestors 'self'; form-action 'self'; base-uri 'self'; object-src 'none'`,
value: `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.google.com https://apis.google.com https://*.vercel-scripts.com https://*.vercel-insights.com https://vercel.live https://*.vercel.live https://vercel.com https://*.vercel.app https://vitals.vercel-insights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://*.googleusercontent.com https://*.google.com https://*.atlassian.com https://cdn.discordapp.com https://*.githubusercontent.com; media-src 'self' blob:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ${env.NEXT_PUBLIC_APP_URL || ''} ${env.OLLAMA_URL || 'http://localhost:11434'} ${env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3002'} ${env.NEXT_PUBLIC_SOCKET_URL?.replace('http://', 'ws://').replace('https://', 'wss://') || 'ws://localhost:3002'} https://*.up.railway.app wss://*.up.railway.app https://api.browser-use.com https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.blob.core.windows.net https://*.vercel-insights.com https://vitals.vercel-insights.com https://*.atlassian.com https://vercel.live https://*.vercel.live https://vercel.com https://*.vercel.app wss://*.vercel.app; frame-src https://drive.google.com https://*.google.com; frame-ancestors 'self'; form-action 'self'; base-uri 'self'; object-src 'none'`,
},
],
},

View File

@@ -1,3 +1,4 @@
import { env } from '@/lib/env'
import { getNodeEnv } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger'
import { getBaseUrl } from '@/lib/urls/utils'
@@ -14,7 +15,7 @@ const getReferer = (): string => {
try {
return getBaseUrl()
} catch (_error) {
return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
return env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
}
}