Files
eden/scripts/lib/infisical.ts
T

175 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { constants } from 'node:fs';
import { open } from 'node:fs/promises';
import { parseEnv } from 'node:util';
import { fileURLToPath } from 'node:url';
export interface VaultProject {
workspaceId: string;
defaultEnvironment: string;
domain: string;
secretPath: string;
}
export const BOOTSTRAP_KEYS = ['INFISICAL_UNIVERSAL_AUTH_CLIENT_ID', 'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET'] as const;
export const APPLICATION_KEYS = [
'EVE_CLIENT_ID', 'EVE_CLIENT_SECRET', 'TYPESAFE_API_KEY', 'TOKEN_ENCRYPTION_KEY',
'CONVEX_BRIDGE_SECRET', 'APP_AUTH_PRIVATE_KEY', 'APP_AUTH_JWKS',
] as const;
export const REQUIRED_KEYS = ['EVE_CLIENT_ID', 'TOKEN_ENCRYPTION_KEY', 'CONVEX_BRIDGE_SECRET', 'APP_AUTH_PRIVATE_KEY', 'APP_AUTH_JWKS'] as const;
export const ADMIN_KEY = 'CONVEX_SELF_HOSTED_ADMIN_KEY';
export type Environment = Record<string, string | undefined>;
export type CliRunner = (args: string[], env: Environment) => Promise<string>;
export function validateProject(input: unknown): VaultProject {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid Infisical project configuration');
const project = input as Record<string, unknown>;
if (typeof project.workspaceId !== 'string' || !/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i.test(project.workspaceId)) throw new Error('Invalid Infisical project ID');
if (typeof project.defaultEnvironment !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(project.defaultEnvironment)) throw new Error('Invalid Infisical environment');
// This repository is deliberately pinned to the operator-authorized vault origin.
// A repository config change alone must never send the bootstrap credential elsewhere.
if (project.domain !== 'https://secrets.jb.codes') throw new Error('Infisical origin is not the approved instance');
if (typeof project.secretPath !== 'string' || !/^\/(?:[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*)?$/.test(project.secretPath)) throw new Error('Invalid Infisical secret path');
return project as unknown as VaultProject;
}
export async function readCredentials(path: string): Promise<Record<string, string>> {
let handle;
try {
// Do not follow a symlink or read a world/group-accessible bootstrap secret.
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const stat = await handle.stat();
if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.() || stat.size > 16_384) {
throw new Error('Invalid credentials file');
}
const parsed = parseEnv(await handle.readFile('utf8'));
const selected: Record<string, string> = {};
for (const name of BOOTSTRAP_KEYS) {
if (!parsed[name]?.trim()) throw new Error('Missing bootstrap credential');
selected[name] = parsed[name];
}
return selected;
} catch {
throw new Error('Cannot read Universal Auth credentials: use a regular, owner-owned 0600 file containing the two documented variables');
} finally {
await handle?.close();
}
}
async function boundedOutput(stream: ReadableStream<Uint8Array>, limit: number): Promise<string> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > limit) throw new Error('Infisical output exceeded its safety limit');
chunks.push(value);
}
} finally { reader.releaseLock(); }
return Buffer.concat(chunks).toString('utf8');
}
async function captureSecretCommand(argv: string[], env: Environment): Promise<string> {
const child = Bun.spawn(argv, { env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
const deadline = setTimeout(() => child.kill('SIGKILL'), 30_000);
deadline.unref();
try {
const [output, , code] = await Promise.all([
boundedOutput(child.stdout, 2 * 1024 * 1024),
boundedOutput(child.stderr, 64 * 1024),
child.exited,
]);
if (code !== 0) throw new Error('CLI failed');
return output;
} catch {
// CLI errors can embed request details. Never echo raw stdout/stderr or credentials.
throw new Error('Credential command failed; check permissions and connectivity (raw output suppressed)');
} finally {
clearTimeout(deadline);
if (child.exitCode === null) child.kill('SIGKILL');
}
}
export const runCli: CliRunner = async (args, env) => {
const executable = fileURLToPath(new URL('../../node_modules/.bin/infisical', import.meta.url));
return captureSecretCommand([executable, ...args], env);
};
export async function localDeploymentAdmin(env: Environment, runner = captureSecretCommand): Promise<string> {
if (!['http://127.0.0.1:3210', 'http://localhost:3210'].includes(env.CONVEX_SELF_HOSTED_URL ?? '')) {
throw new Error('Local admin derivation is restricted to this host’s Convex deployment');
}
// The backend derives its admin credential from its persistent random instance
// secret. Docker-host access is already administrative; no second secret file
// or write-enabled Infisical identity is required. Only explicit --deployment
// commands receive this key; Compose never forwards it to the web container.
const key = (await runner(['docker', 'compose', 'exec', '-T', 'backend', './generate_admin_key.sh'], cleanEnvironment(env))).trim();
if (!key || /\s/.test(key) || key.length > 16_384) throw new Error('Unexpected local admin response; output suppressed');
return key;
}
export function parseExport(text: string): Record<string, string> {
try {
const parsed: unknown = JSON.parse(text);
const values: Record<string, string> = Object.create(null);
const entries: unknown[] = Array.isArray(parsed)
? parsed
: parsed && typeof parsed === 'object'
? Object.entries(parsed).map(([key, value]) => ({ key, value }))
: [];
if (!parsed || typeof parsed !== 'object') throw new Error();
for (const entry of entries) {
if (!entry || typeof entry !== 'object') throw new Error();
const row = entry as Record<string, unknown>;
const key = row.key ?? row.secretKey;
const value = row.value ?? row.secretValue;
if (typeof key !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(key) || typeof value !== 'string' || Object.hasOwn(values, key)) throw new Error();
values[key] = value;
}
return values;
} catch { throw new Error('Invalid Infisical export; values suppressed'); }
}
export async function fetchSecrets(project: VaultProject, credentials: Record<string, string>, runner: CliRunner = runCli, base: Environment = process.env): Promise<Record<string, string>> {
validateProject(project);
for (const name of BOOTSTRAP_KEYS) if (!credentials[name]) throw new Error(`Missing ${name}`);
const env = cleanEnvironment(base);
const common = ['--domain', project.domain, '--silent', '--telemetry=false'];
const bootstrap = Object.fromEntries(BOOTSTRAP_KEYS.map(key => [key, credentials[key]]));
const token = (await runner(['login', '--method=universal-auth', '--plain', ...common], { ...env, ...bootstrap })).trim();
if (!token || /\s/.test(token) || token.length > 16_384) throw new Error('Unexpected Infisical login response; values suppressed');
const output = await runner([
'export', '--projectId', project.workspaceId, '--env', project.defaultEnvironment, '--path', project.secretPath,
'--format=json', '--expand=false', '--include-imports=false', '--secret-overriding=false', ...common,
], { ...env, INFISICAL_TOKEN: token });
return parseExport(output);
}
export function cleanEnvironment(base: Environment): Environment {
const managed = new Set<string>([ADMIN_KEY, ...APPLICATION_KEYS]);
return Object.fromEntries(Object.entries(base).filter(([key]) => !key.startsWith('INFISICAL_') && !managed.has(key)));
}
const PUBLIC_KEYS = new Set([
'APP_MODE', 'APP_RELEASE_ID', 'APP_CAPABILITY_ALLOWLIST', 'APP_ORIGIN', 'APP_AUTH_ISSUER', 'EVE_CALLBACK_URL', 'PUBLIC_CONVEX_URL',
'CONVEX_URL', 'CONVEX_SELF_HOSTED_URL', 'CONVEX_SITE_ORIGIN', 'REFERENCE_DIR', 'ESI_USER_AGENT',
'WEB_BIND_ADDRESS', 'CONVEX_BIND_ADDRESS', 'WEB_PORT', 'CONVEX_PORT', 'CONVEX_SITE_PORT',
'CONVEX_IMAGE', 'CONVEX_DASHBOARD_IMAGE', 'RELEASE_ENV',
]);
export function applicationEnvironment(base: Environment, secrets: Record<string, string>, publicConfig: Environment, deployment = false): Environment {
const env = cleanEnvironment(base);
// Public file is the configured deployment, not defaults inherited from Bun's .env.local.
for (const [key, value] of Object.entries(publicConfig)) {
if (!PUBLIC_KEYS.has(key) || typeof value !== 'string') throw new Error('Unexpected public deployment configuration key');
env[key] = value;
}
// Managed app keys must come from the selected vault, never a stale ambient .env.
for (const key of APPLICATION_KEYS) {
delete env[key];
if (secrets[key] !== undefined) env[key] = secrets[key];
}
if (deployment && secrets[ADMIN_KEY]) env[ADMIN_KEY] = secrets[ADMIN_KEY];
return env;
}