server/env.ts
Rama main · réplica de lectura del repositorio original.
/// <reference types="@cloudflare/workers-types" />
/// <reference path="./worker-configuration.d.ts" />
import type { SecurityPolicy } from '@shared/api/security'
import { z } from 'zod'
export interface EmailAddress {
email: string
name: string
}
export interface EmailMessageBuilder {
to: string | string[]
from: string | EmailAddress
subject: string
html?: string
text?: string
replyTo?: string | EmailAddress
headers?: Record<string, string>
}
export interface EmailSendResult {
messageId: string
}
export interface SendEmail {
send(message: EmailMessageBuilder): Promise<EmailSendResult>
}
/**
* Bindings + declared vars (DB, ASSET_BUCKET, ASSETS, and BETTER_AUTH_SECRET)
* are typed from wrangler.toml via the generated
* `Cloudflare.Env` (regenerate with `pnpm cf-typegen`). EMAIL is narrowed to our
* transactional `SendEmail` wrapper, and the optional runtime config vars below are
* declared by hand because they are deployment secrets/vars, not wrangler bindings.
*/
type RuntimeEnvOverrides = {
EMAIL?: SendEmail
EMAIL_FROM?: string
EMAIL_FROM_NAME?: string
EXTERNAL_HTTP?: Fetcher
BETTER_AUTH_URL?: string
CREDENTIAL_ENCRYPTION_KEY?: string
TRUSTED_ORIGINS?: string
MFA_POLICY?: string
PASSKEY_ENABLED?: string
SESSION_DURATION_SECONDS?: string
SESSION_UPDATE_AGE_SECONDS?: string
SESSION_FRESH_AGE_SECONDS?: string
SESSION_COOKIE_CACHE_SECONDS?: string
WEBAUTHN_RP_ID?: string
WEBAUTHN_RP_NAME?: string
WEBAUTHN_ORIGINS?: string
}
export type Env = Omit<Cloudflare.Env, keyof RuntimeEnvOverrides> & RuntimeEnvOverrides
export interface RuntimeConfig {
authSecret: string
baseURL: string
credentialEncryptionKey: string
emailFrom?: string
emailFromName?: string
trustedOrigins: string[]
securityPolicy: SecurityPolicy
}
export function validateEnv(env: Env, requestUrl: string): RuntimeConfig {
const origin = new URL(requestUrl).origin
const baseURL = parseOrigin(env.BETTER_AUTH_URL || origin, 'BETTER_AUTH_URL')
const trustedOrigins = parseTrustedOrigins(env.TRUSTED_ORIGINS, baseURL)
const securityPolicy = parseSecurityPolicy(env, baseURL, trustedOrigins)
if (!env.DB) {
throw new Error('DB binding is not configured for this deployment.')
}
if (!env.ASSETS) {
throw new Error('ASSETS binding is not configured for this deployment.')
}
if (!env.ASSET_BUCKET) {
throw new Error('ASSET_BUCKET binding is not configured for this deployment.')
}
if (!env.BETTER_AUTH_SECRET) {
throw new Error('BETTER_AUTH_SECRET is not configured for this deployment.')
}
if (!env.CREDENTIAL_ENCRYPTION_KEY || env.CREDENTIAL_ENCRYPTION_KEY.length < 32) {
throw new Error('CREDENTIAL_ENCRYPTION_KEY must contain at least 32 characters.')
}
if (env.EMAIL_FROM && !z.email().safeParse(env.EMAIL_FROM).success) {
throw new Error('EMAIL_FROM must be a valid email address.')
}
return {
authSecret: env.BETTER_AUTH_SECRET,
baseURL,
credentialEncryptionKey: env.CREDENTIAL_ENCRYPTION_KEY,
emailFrom: env.EMAIL_FROM,
emailFromName: env.EMAIL_FROM_NAME,
trustedOrigins,
securityPolicy,
}
}
function parseSecurityPolicy(env: Env, baseURL: string, trustedOrigins: string[]): SecurityPolicy {
const rpId = env.WEBAUTHN_RP_ID ?? new URL(baseURL).hostname
if (!rpId || rpId.includes('/') || rpId.includes(':')) {
throw new Error(`WEBAUTHN_RP_ID must be a domain name or localhost: ${rpId}`)
}
return {
mfa: {
mode: parseMfaPolicy(env.MFA_POLICY),
authenticatorAppEnabled: true,
emailOtpEnabled: false,
backupCodesEnabled: true,
},
passkeys: {
enabled: parseBoolean(env.PASSKEY_ENABLED, true, 'PASSKEY_ENABLED'),
rpId,
rpName: env.WEBAUTHN_RP_NAME || 'Realmroot',
origins: env.WEBAUTHN_ORIGINS ? parseTrustedOrigins(env.WEBAUTHN_ORIGINS, baseURL) : trustedOrigins,
},
sessions: {
expiresInSeconds: parsePositiveInteger(
env.SESSION_DURATION_SECONDS,
60 * 60 * 24 * 7,
'SESSION_DURATION_SECONDS',
),
updateAgeSeconds: parseNonNegativeInteger(
env.SESSION_UPDATE_AGE_SECONDS,
60 * 60 * 24,
'SESSION_UPDATE_AGE_SECONDS',
),
freshAgeSeconds: parseNonNegativeInteger(
env.SESSION_FRESH_AGE_SECONDS,
60 * 60 * 24,
'SESSION_FRESH_AGE_SECONDS',
),
cookieCacheSeconds: parsePositiveInteger(
env.SESSION_COOKIE_CACHE_SECONDS,
60 * 5,
'SESSION_COOKIE_CACHE_SECONDS',
),
},
password: {
minLength: 8,
requiredCharacterTypes: 1,
customWords: [],
rejectUserInfo: true,
rejectSequential: true,
rejectCustomWords: false,
},
captcha: {
enabled: false,
provider: 'turnstile',
siteKey: '',
projectId: null,
secretKey: '',
},
blocklist: {
blockSubaddressing: false,
entries: [],
},
}
}
function parseMfaPolicy(value: string | undefined): SecurityPolicy['mfa']['mode'] {
if (value === undefined || value === 'optional' || value === 'required') {
return value ?? 'optional'
}
throw new Error('MFA_POLICY must be one of: optional, required')
}
function parseBoolean(value: string | undefined, defaultValue: boolean, label: string): boolean {
if (value === undefined) {
return defaultValue
}
if (value === 'true') {
return true
}
if (value === 'false') {
return false
}
throw new Error(`${label} must be true or false`)
}
function parsePositiveInteger(value: string | undefined, defaultValue: number, label: string): number {
const parsed = parseNonNegativeInteger(value, defaultValue, label)
if (parsed === 0) {
throw new Error(`${label} must be greater than 0`)
}
return parsed
}
function parseNonNegativeInteger(value: string | undefined, defaultValue: number, label: string): number {
if (value === undefined) {
return defaultValue
}
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed < 0) {
throw new Error(`${label} must be a non-negative integer`)
}
return parsed
}
function parseTrustedOrigins(value: string | undefined, baseURL: string): string[] {
const origins = (
value
? value
.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
: [baseURL]
).map((origin) => parseOrigin(origin))
return [...new Set(origins)]
}
function parseOrigin(value: string, label = 'TRUSTED_ORIGINS entry'): string {
const url = new URL(value)
if (url.pathname !== '/' || url.search || url.hash) {
throw new Error(`${label} must be an origin: ${value}`)
}
return url.origin
}