diff --git a/src/app/api/env-check/route.ts b/src/app/api/env-check/route.ts new file mode 100644 index 000000000..a2b090958 --- /dev/null +++ b/src/app/api/env-check/route.ts @@ -0,0 +1,158 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { getEnvVariable } from '@/lib/dotenvx'; + +const ENV_CHECK_SECRET = getEnvVariable('ENV_CHECK_SECRET'); + +type Deployment = { + name: string; + url: string; +}; + +const DEPLOYMENTS: Deployment[] = [ + { name: 'kilocode-app', url: getEnvVariable('KILOCODE_APP_URL') }, + { name: 'kilocode-global-app', url: getEnvVariable('KILOCODE_GLOBAL_APP_URL') }, + { name: 'kilocode-app-staging', url: getEnvVariable('KILOCODE_STAGING_APP_URL') }, +]; + +type EnvCheckSuccess = { + status: 'ok'; + message: string; + deployments: string[]; +}; + +type KeyDrift = { + key: string; + missingFrom?: string[]; + mismatchBetween?: string[]; +}; + +type EnvCheckDrift = { + status: 'drift'; + message: string; + differences: KeyDrift[]; +}; + +type EnvCheckError = { + status: 'error'; + message: string; + failures: { name: string; error: string }[]; +}; + +type FetchResult = { + name: string; + entries: Record | null; + error: string | null; +}; + +async function fetchEnvKeys(deployment: Deployment, secret: string): Promise { + try { + const response = await fetch(`${deployment.url}/api/env-keys`, { + headers: { Authorization: `Bearer ${secret}` }, + }); + + if (!response.ok) { + return { + name: deployment.name, + entries: null, + error: `HTTP ${response.status}: ${response.statusText}`, + }; + } + + const entries: Record = await response.json(); + return { name: deployment.name, entries, error: null }; + } catch (err) { + return { + name: deployment.name, + entries: null, + error: err instanceof Error ? err.message : 'Unknown error', + }; + } +} + +/** + * Fetches /api/env-keys from all three deployments, compares keys and hashed + * values, and reports any drift. Protected by ENV_CHECK_SECRET bearer token. + * Never exposes actual env values or hashes — only key names and drift type. + */ +export async function GET( + request: NextRequest +): Promise> { + const authHeader = request.headers.get('authorization'); + if (!ENV_CHECK_SECRET || authHeader !== `Bearer ${ENV_CHECK_SECRET}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const configuredDeployments = DEPLOYMENTS.filter(d => d.url); + if (configuredDeployments.length === 0) { + return NextResponse.json( + { error: 'No deployment URLs configured' }, + { status: 500 } + ); + } + + const results = await Promise.all( + configuredDeployments.map(d => fetchEnvKeys(d, ENV_CHECK_SECRET)) + ); + + const failures = results.filter(r => r.error !== null); + if (failures.length > 0) { + return NextResponse.json( + { + status: 'error' as const, + message: `Failed to fetch env keys from ${failures.length} deployment(s)`, + failures: failures.map(f => ({ name: f.name, error: f.error ?? 'Unknown error' })), + }, + { status: 502 } + ); + } + + // Collect the union of all keys across all deployments + const allKeys = new Set(); + for (const result of results) { + if (result.entries) { + for (const key of Object.keys(result.entries)) { + allKeys.add(key); + } + } + } + + // Find keys missing from any deployment, or with mismatched hashed values + const differences: KeyDrift[] = []; + for (const key of [...allKeys].sort()) { + const presentIn = results.filter(r => r.entries && key in r.entries); + const missingFrom = results + .filter(r => r.entries && !(key in r.entries)) + .map(r => r.name); + + if (missingFrom.length > 0) { + differences.push({ key, missingFrom }); + } else if (presentIn.length > 1) { + // All deployments have this key — check whether hashed values match + const hashes = presentIn.map(r => r.entries?.[key]); + const unique = new Set(hashes); + if (unique.size > 1) { + differences.push({ key, mismatchBetween: presentIn.map(r => r.name) }); + } + } + } + + const deploymentNames = configuredDeployments.map(d => d.name); + + if (differences.length === 0) { + return NextResponse.json({ + status: 'ok' as const, + message: 'All deployments have matching env vars', + deployments: deploymentNames, + }); + } + + return NextResponse.json( + { + status: 'drift' as const, + message: `Found ${differences.length} env var(s) with drift across deployments`, + differences, + }, + { status: 200 } + ); +} diff --git a/src/app/api/env-keys/route.ts b/src/app/api/env-keys/route.ts new file mode 100644 index 000000000..c73a9d40b --- /dev/null +++ b/src/app/api/env-keys/route.ts @@ -0,0 +1,59 @@ +import { createHash } from 'crypto'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { getEnvVariable } from '@/lib/dotenvx'; + +const ENV_CHECK_SECRET = getEnvVariable('ENV_CHECK_SECRET'); + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +// Vercel-managed env vars that legitimately differ between deployments +const IGNORED_KEYS = new Set([ + 'VERCEL', + 'VERCEL_ENV', + 'VERCEL_URL', + 'VERCEL_BRANCH_URL', + 'VERCEL_PROJECT_PRODUCTION_URL', + 'VERCEL_GIT_COMMIT_SHA', + 'VERCEL_GIT_COMMIT_MESSAGE', + 'VERCEL_GIT_COMMIT_AUTHOR_LOGIN', + 'VERCEL_GIT_COMMIT_AUTHOR_NAME', + 'VERCEL_GIT_COMMIT_REF', + 'VERCEL_GIT_PREVIOUS_SHA', + 'VERCEL_GIT_PROVIDER', + 'VERCEL_GIT_PULL_REQUEST_ID', + 'VERCEL_GIT_REPO_ID', + 'VERCEL_GIT_REPO_OWNER', + 'VERCEL_GIT_REPO_SLUG', + 'VERCEL_REGION', + 'VERCEL_DEPLOYMENT_ID', + 'VERCEL_SKEW_PROTECTION_ENABLED', + 'VERCEL_AUTOMATION_BYPASS_SECRET', +]); + +/** + * Returns the set of process.env keys available at runtime with SHA-256 + * hashed values, filtered to exclude Vercel-managed vars that differ + * between deployments. Protected by ENV_CHECK_SECRET bearer token. + */ +export async function GET( + request: NextRequest +): Promise>> { + const authHeader = request.headers.get('authorization'); + if (!ENV_CHECK_SECRET || authHeader !== `Bearer ${ENV_CHECK_SECRET}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const keys = Object.keys(process.env) + .filter(key => !IGNORED_KEYS.has(key)) + .sort(); + + const hashedEntries: Record = {}; + for (const key of keys) { + hashedEntries[key] = sha256(process.env[key] ?? '__undefined__'); + } + + return NextResponse.json(hashedEntries); +}