175 lines
8.8 KiB
TypeScript
175 lines
8.8 KiB
TypeScript
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_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',
|
||
]);
|
||
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;
|
||
}
|