/** * Configuration, read once at boot and validated loudly. * * A misconfigured deployment should fail to start with a clear message rather * than start successfully and behave subtly wrong. The warnings below are the * cases where PIG *can* run but an operator almost certainly did not intend * the resulting behaviour. */ import { z } from 'zod'; /** * Parse a boolean from an environment variable. * * NOT `z.coerce.boolean()`, which calls `Boolean(value)` — so the string * `"false"` becomes `true`, along with `"0"`, `"no"` and `"off"`. Every * feature flag set to `false` would silently be on, which is exactly the * class of bug that gets discovered in production by reading a startup * warning that should not have appeared. */ const envBoolean = (defaultValue: boolean) => z .string() .optional() .transform((value) => { if (value == null || value.trim() === '') return defaultValue; return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()); }); const optionalEnvString = (value: unknown) => typeof value === 'string' && value.trim() === '' ? undefined : value; const schema = z.object({ DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'), SUPABASE_URL: z.string().url().optional(), /* * OIDC — the on-premises path. * * A customer running PIG inside their own network already has an identity * provider. Setting PIG_OIDC_ISSUER switches authentication to it and takes * precedence over any Supabase values left in the environment file. */ PIG_OIDC_ISSUER: z.string().url().optional(), /** Optional. Discovered from the issuer when omitted. */ PIG_OIDC_JWKS_URI: z.string().url().optional(), /** Strongly recommended — see the boot warning. */ PIG_OIDC_AUDIENCE: z.string().optional(), /** Comma-separated, in preference order. Defaults cover most providers. */ PIG_OIDC_EMAIL_CLAIMS: z.string().optional(), SUPABASE_ANON_KEY: z.string().optional(), SUPABASE_SERVICE_KEY: z.string().optional(), PIG_PORT: z.coerce.number().int().positive().default(8920), PIG_PUBLIC_URL: z.string().default('http://localhost:8920'), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), PIG_ADMIN_EMAILS: z.string().default(''), PIG_INVITE_CODE: z.string().optional(), PRIME_API_KEY: z.string().optional(), PRIME_API_BASE: z.string().default('https://api.primeintellect.ai'), PRIME_SYNC_ENABLED: envBoolean(false), PRIME_SYNC_INTERVAL_MINUTES: z.coerce.number().int().positive().default(30), /** Base64-encoded 32-byte key. Secrets written in the admin UI require it. */ PIG_SETTINGS_ENCRYPTION_KEY: z.string().optional(), PIGGY_ENABLED: envBoolean(false), ANTHROPIC_API_KEY: z.string().optional(), PIGGY_MODEL: z.string().default('nvidia/nemotron-3-nano-30b-a3b'), PIGGY_INFERENCE_BASE: z.string().url().default('https://api.pinference.ai/api/v1'), PIGGY_LEASE_SECONDS: z.coerce.number().int().positive().default(300), PIGGY_INTERNAL_URL: z.preprocess( (value) => (value === '' ? undefined : value), z.string().url().optional(), ), PIGGY_INTERNAL_TOKEN: z.preprocess( (value) => (value === '' ? undefined : value), z.string().min(32).optional(), ), SLACK_BOT_TOKEN: z.string().optional(), SLACK_SIGNING_SECRET: z.string().optional(), BUZZ_RELAY_URL: z.preprocess(optionalEnvString, z.string().url().optional()), BUZZ_PRIVATE_KEY: z.preprocess(optionalEnvString, z.string().min(1).optional()), BUZZ_AUTH_TAG: z.preprocess(optionalEnvString, z.string().min(1).optional()), NOTION_CLIENT_ID: z.preprocess(optionalEnvString, z.string().min(1).optional()), NOTION_CLIENT_SECRET: z.preprocess(optionalEnvString, z.string().min(1).optional()), NOTION_REDIRECT_URI: z.preprocess(optionalEnvString, z.string().url().optional()), GOOGLE_CLIENT_ID: z.preprocess(optionalEnvString, z.string().min(1).optional()), GOOGLE_CLIENT_SECRET: z.preprocess(optionalEnvString, z.string().min(1).optional()), GOOGLE_REDIRECT_URI: z.preprocess(optionalEnvString, z.string().url().optional()), }).superRefine((value, context) => { const buzzConfigured = Boolean( value.BUZZ_RELAY_URL || value.BUZZ_PRIVATE_KEY || value.BUZZ_AUTH_TAG, ); if (buzzConfigured && !value.BUZZ_RELAY_URL) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['BUZZ_RELAY_URL'], message: 'BUZZ_RELAY_URL is required when Buzz delivery is configured.', }); } if (buzzConfigured && !value.BUZZ_PRIVATE_KEY) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['BUZZ_PRIVATE_KEY'], message: 'BUZZ_PRIVATE_KEY is required when Buzz delivery is configured.', }); } const notionConfigured = Boolean( value.NOTION_CLIENT_ID || value.NOTION_CLIENT_SECRET || value.NOTION_REDIRECT_URI, ); for (const key of ['NOTION_CLIENT_ID', 'NOTION_CLIENT_SECRET', 'NOTION_REDIRECT_URI'] as const) { if (notionConfigured && !value[key]) { context.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: `${key} is required when Notion import is configured.`, }); } } if (notionConfigured && !hasValidEncryptionKey(value.PIG_SETTINGS_ENCRYPTION_KEY)) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['PIG_SETTINGS_ENCRYPTION_KEY'], message: 'A base64-encoded 32-byte PIG_SETTINGS_ENCRYPTION_KEY is required for Notion OAuth.', }); } const googleConfigured = Boolean( value.GOOGLE_CLIENT_ID || value.GOOGLE_CLIENT_SECRET || value.GOOGLE_REDIRECT_URI, ); for (const key of ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'GOOGLE_REDIRECT_URI'] as const) { if (googleConfigured && !value[key]) { context.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: `${key} is required when Google Sheets import is configured.`, }); } } if (googleConfigured && !hasValidEncryptionKey(value.PIG_SETTINGS_ENCRYPTION_KEY)) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['PIG_SETTINGS_ENCRYPTION_KEY'], message: 'A base64-encoded 32-byte PIG_SETTINGS_ENCRYPTION_KEY is required for Google OAuth.', }); } if (value.GOOGLE_REDIRECT_URI) { try { const redirect = new URL(value.GOOGLE_REDIRECT_URI); const publicUrl = new URL(value.PIG_PUBLIC_URL); if ( redirect.origin !== publicUrl.origin || redirect.pathname !== '/oauth/google/callback' || redirect.search || redirect.hash ) { context.addIssue({ code: z.ZodIssueCode.custom, path: ['GOOGLE_REDIRECT_URI'], message: 'GOOGLE_REDIRECT_URI must use the PIG_PUBLIC_URL origin and exact /oauth/google/callback path.', }); } } catch { context.addIssue({ code: z.ZodIssueCode.custom, path: ['GOOGLE_REDIRECT_URI'], message: 'GOOGLE_REDIRECT_URI must use the PIG_PUBLIC_URL origin and exact /oauth/google/callback path.', }); } } }); function hasValidEncryptionKey(value: string | undefined): boolean { if (!value) return false; const decoded = Buffer.from(value, 'base64'); return decoded.length === 32 && decoded.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, ''); } export type Config = z.infer & { adminEmails: string[]; isProduction: boolean; }; export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { const parsed = schema.safeParse(env); if (!parsed.success) { const issues = parsed.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`); throw new Error(`Invalid configuration:\n${issues.join('\n')}`); } const adminEmails = parsed.data.PIG_ADMIN_EMAILS.split(',') .map((e) => e.trim().toLowerCase()) .filter(Boolean); const config: Config = { ...parsed.data, adminEmails, isProduction: parsed.data.NODE_ENV === 'production', }; warnOnFootguns(config); return config; } function warnOnFootguns(config: Config): void { const warn = (message: string) => console.warn(`[pig] WARNING: ${message}`); if (config.adminEmails.length === 0) { // Safe default, but worth saying out loud: with no admins nobody can // manage invites or settings through the UI. warn('PIG_ADMIN_EMAILS is empty — no user will have platform-admin rights.'); } // Keyed on BOTH providers, not just Supabase. An OIDC deployment has // authentication and this warning previously claimed it did not — which is // worse than saying nothing, because an operator reading "authentication is // DISABLED" on a correctly secured install learns to ignore the warnings. if (!config.SUPABASE_URL && !config.PIG_OIDC_ISSUER) { warn( 'No identity provider is configured (SUPABASE_URL or PIG_OIDC_ISSUER) — ' + 'authentication is DISABLED and every request runs as the development ' + 'user. Never do this in production.', ); } if (config.PIG_OIDC_ISSUER && config.SUPABASE_URL) { warn( 'Both PIG_OIDC_ISSUER and SUPABASE_URL are set — OIDC takes precedence and ' + 'Supabase will not be used for authentication.', ); } if (config.PIG_OIDC_ISSUER && !config.PIG_OIDC_AUDIENCE) { // Not fatal, because some providers issue single-audience tokens where it // adds nothing — but on a shared corporate tenant this is the difference // between "a token for PIG" and "a token for anything in the company". warn( 'PIG_OIDC_AUDIENCE is not set. Any token your identity provider issued for ' + 'ANY application in the same tenant will be accepted here. Set it unless ' + 'you are certain that is safe.', ); } if (config.PIG_OIDC_ISSUER && config.SUPABASE_SERVICE_KEY) { warn( 'SUPABASE_SERVICE_KEY is set while running on OIDC. Self-registration mints ' + 'Supabase accounts, which an OIDC deployment does not use — unset it and ' + 'provision users through your identity provider instead.', ); } if (config.isProduction && !config.SUPABASE_URL && !config.PIG_OIDC_ISSUER) { throw new Error( 'Refusing to start: NODE_ENV=production with neither SUPABASE_URL nor ' + 'PIG_OIDC_ISSUER would serve the entire CRM unauthenticated.', ); } if (config.PRIME_SYNC_ENABLED && !config.PRIME_API_KEY) { warn('PRIME_SYNC_ENABLED is on but PRIME_API_KEY is unset — sync will not run.'); } if (config.PIGGY_ENABLED && (!config.PIGGY_INTERNAL_URL || !config.PIGGY_INTERNAL_TOKEN)) { warn( 'PIGGY_ENABLED is on but the internal URL or token is unset — interactive chat will be unavailable.', ); } if (config.SUPABASE_SERVICE_KEY) { // Present legitimately for admin provisioning, but it is the most powerful // credential in the deployment and most installs do not need it. warn( 'SUPABASE_SERVICE_KEY is set. It is only needed for administrative user ' + 'provisioning; unset it if you are not using that.', ); } }