diff --git a/.gitignore b/.gitignore index 022a176..be53370 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ node_modules /build /test .env + +# Nix stuff +/result +.direnv \ No newline at end of file diff --git a/app/server/config/loader.ts b/app/server/config/loader.ts index 2a6fc0f..d2e8948 100644 --- a/app/server/config/loader.ts +++ b/app/server/config/loader.ts @@ -1,5 +1,5 @@ import { constants, access, readFile } from 'node:fs/promises'; -import { env, exit } from 'node:process'; +import { env } from 'node:process'; import { type } from 'arktype'; import { configDotenv } from 'dotenv'; import { parseDocument } from 'yaml'; @@ -7,10 +7,56 @@ import log from '~/utils/log'; import { EnvOverrides, envVariables } from './env'; import { HeadplaneConfig, + PartialHeadplaneConfig, headplaneConfig, partialHeadplaneConfig, } from './schema'; +// A whitelist of known secret paths that should be processed +// and loaded from files. +const SECRET_PATHS = new Set([ + 'server.cookie_secret_path', + 'server.agent.authkey_path', + 'oidc.client_secret_path', + 'oidc.headscale_api_key_path', + 'headscale.api_key_path', + 'headscale.tls_cert_path', + 'headscale.tls_key_path', +]); + +// Custom Error for configuration issues +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConfigError'; + } +} + +// Helper function for environment variable interpolation in file paths +function interpolateEnvVars(filePath: string): string { + const interpolatedPath = filePath.replace( + /\$\{(.*?)\}/g, + (match, varName) => { + const value = env[varName]; + if (value === undefined) { + throw new ConfigError( + `Environment variable "${varName}" not found for path interpolation in "${filePath}"`, + ); + } + return value; + }, + ); + if (interpolatedPath !== filePath) { + log.debug( + 'config', + 'Interpolated path "%s" to "%s"', + filePath, + interpolatedPath, + ); + } + return interpolatedPath; +} + // loadConfig is a has a lifetime of the entire application and is // used to load the configuration for Headplane. It is called once. // @@ -18,35 +64,44 @@ import { // But this may not be necessary as a use-case anyways export async function loadConfig({ loadEnv, path }: EnvOverrides) { log.debug('config', 'Loading configuration file: %s', path); - const valid = await validateConfigPath(path); - if (!valid) { - exit(1); + + const isValidPath = await validateConfigPath(path); + if (!isValidPath) { + throw new ConfigError(`Config file not accessible: "${path}"`); } - const data = await loadConfigFile(path); - if (!data) { - exit(1); + const rawData = await loadConfigFile(path); + if (typeof rawData !== 'object' || rawData === null) { + throw new ConfigError('Loaded configuration data is not a valid object.'); } - let config = validateConfig({ ...data, debug: log.debugEnabled }); - if (!config) { - exit(1); - } + // Initial validation + const initialValidatedConfig = validateConfig({ + ...(rawData as Record), + debug: log.debugEnabled, + }); + + // Deep clone before mutation by loadSecretsFromFiles + let configObject = JSON.parse(JSON.stringify(initialValidatedConfig)); + + // Process *_path fields from the YAML file itself, regardless of env loading + await loadSecretsFromFiles(configObject); if (!loadEnv) { log.debug('config', 'Environment variable overrides are disabled'); log.debug('config', 'This also disables the loading of a .env file'); - return config; + return validateConfig(configObject); // Re-validate after potential modifications by loadSecretsFromFiles } log.info('config', 'Loading a .env file (if available)'); configDotenv({ override: true }); - config = coalesceEnv(config); - if (!config) { - exit(1); - } - return config; + configObject = coalesceEnv(configObject); + + // Processes any new or modified secret paths that came from environment variables + await loadSecretsFromFiles(configObject); + + return validateConfig(configObject); } export async function hp_loadConfig() { @@ -68,14 +123,15 @@ export async function hp_loadConfig() { // } } -async function validateConfigPath(path: string) { +async function validateConfigPath(path: string): Promise { try { await access(path, constants.F_OK | constants.R_OK); log.info('config', 'Found a valid configuration file at %s', path); return true; - } catch (error) { + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); log.error('config', 'Unable to read a configuration file at %s', path); - log.error('config', '%s', error); + log.error('config', '%s', message); return false; } } @@ -87,11 +143,14 @@ async function loadConfigFile(path: string): Promise { const configYaml = parseDocument(data); if (configYaml.errors.length > 0) { log.error('config', 'Cannot parse configuration file at %s', path); + let errorMessages = ''; for (const error of configYaml.errors) { log.error('config', ` - ${error.toString()}`); + errorMessages += `${error.toString()}\n`; } - - return false; + throw new ConfigError( + `YAML parsing error in "${path}":\n${errorMessages}`, + ); } if (configYaml.warnings.length > 0) { @@ -106,47 +165,39 @@ async function loadConfigFile(path: string): Promise { } return configYaml.toJSON() as unknown; - } catch (e) { + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); log.error('config', 'Error reading configuration file at %s', path); - log.error('config', '%s', e); - return false; + log.error('config', '%s', message); + if (e instanceof ConfigError) throw e; + throw new ConfigError(`File read error for "${path}": ${message}`); } } -export function validateConfig(config: unknown) { +export function validateConfig(config: unknown): HeadplaneConfig { log.debug('config', 'Validating Headplane configuration'); const result = headplaneConfig(config); + if (result instanceof type.errors) { log.error('config', 'Error validating Headplane configuration:'); + let errorSummary = ''; for (const [number, error] of result.entries()) { log.error('config', ` - (${number}): ${error.toString()}`); + errorSummary += `(${number}): ${error.toString()}\n`; } - - return; + throw new ConfigError(`Configuration validation failed:\n${errorSummary}`); } - return result; } -function coalesceEnv(config: HeadplaneConfig) { +function coalesceEnv(config: HeadplaneConfig): HeadplaneConfig { const envConfig: Record = {}; const rootKeys: string[] = Object.values(envVariables); - // Typescript is still insanely stupid at nullish filtering const vars = Object.entries(env).filter(([key, value]) => { - if (!value) { - return false; - } - - if (!key.startsWith('HEADPLANE_')) { - return false; - } - - // Filter out the rootEnv configurations - if (rootKeys.includes(key)) { - return false; - } - + if (!value) return false; + if (!key.startsWith('HEADPLANE_')) return false; + if (rootKeys.includes(key)) return false; return true; }) as [string, string][]; @@ -157,43 +208,123 @@ function coalesceEnv(config: HeadplaneConfig) { 'config', ` - ${key}=${new Array(value.length).fill('*').join('')}`, ); - let current = envConfig; while (configPath.length > 1) { - const path = configPath.shift() as string; - if (!(path in current)) { - current[path] = {}; - } - - current = current[path] as Record; + const pathPart = configPath.shift() as string; + if (!(pathPart in current)) current[pathPart] = {}; + current = current[pathPart] as Record; } - current[configPath[0]] = value; } + // coalesceConfig will throw ConfigError if validation of env vars fails. + // If it succeeds, toMerge will be a valid PartialHeadplaneConfig. const toMerge = coalesceConfig(envConfig); - if (!toMerge) { - return; - } - // Deep merge the environment variables into the configuration - // This will overwrite any existing values in the configuration - return deepMerge(config, toMerge); + // If coalesceConfig did not throw, proceed to merge. + return deepMerge(config, toMerge as DeepPartial); } -export function coalesceConfig(config: unknown) { +export function coalesceConfig(config: unknown): PartialHeadplaneConfig { log.debug('config', 'Revalidating config after coalescing variables'); const out = partialHeadplaneConfig(config); + if (out instanceof type.errors) { - log.error('config', 'Error parsing variables:'); + log.error( + 'config', + 'Error parsing environment variables into partial config:', + ); + let errorSummary = ''; for (const [number, error] of out.entries()) { log.error('config', ` - (${number}): ${error.toString()}`); + errorSummary += `(${number}): ${error.toString()}\n`; + } + throw new ConfigError( + `Environment variable validation failed:\n${errorSummary}`, + ); + } + return out; +} + +// Safely interpolates environment variables with error handling +function safeInterpolateEnvVars(value: string, fullKeyPath: string): string { + try { + return interpolateEnvVars(value); + } catch (e: unknown) { + if (e instanceof ConfigError) throw e; + const message = e instanceof Error ? e.message : String(e); + log.error('config', 'Interpolation error for %s: %s', fullKeyPath, message); + throw new ConfigError(`Interpolation error for ${fullKeyPath}: ${message}`); + } +} + +// Processes a secret path field by reading the file and setting the value +async function processSecretPath( + configObject: Record, + key: string, + value: string, + fullKeyPath: string, +): Promise { + const valueKey = key.substring(0, key.length - '_path'.length); + const processedPath = safeInterpolateEnvVars(value, fullKeyPath); + + log.debug( + 'config', + 'Loading value for "%s" from file (via %s): %s', + valueKey, + fullKeyPath, + processedPath, + ); + + try { + const secretContent = await readFile(processedPath, 'utf8'); + configObject[valueKey] = secretContent.trim(); + delete configObject[key]; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + log.error( + 'config', + 'Failed to read file "%s" for %s: %s', + processedPath, + fullKeyPath, + message, + ); + throw new ConfigError( + `File read error for ${fullKeyPath} (path: ${processedPath}): ${message}`, + ); + } +} + +// Recursively processes the config tree to find and evaluate secret paths +// and loads their file contents into the config. +async function loadSecretsFromFiles( + currentConfigLevel: Record, + currentPathPrefix = '', +): Promise { + for (const key in currentConfigLevel) { + if (!Object.prototype.hasOwnProperty.call(currentConfigLevel, key)) { + continue; // Skip inherited properties } - return; - } + const value = currentConfigLevel[key]; + const fullKeyPath = currentPathPrefix ? `${currentPathPrefix}.${key}` : key; - return out; + if (typeof value === 'string') { + if (SECRET_PATHS.has(fullKeyPath)) { + await processSecretPath(currentConfigLevel, key, value, fullKeyPath); + } else { + currentConfigLevel[key] = safeInterpolateEnvVars(value, fullKeyPath); + } + } + // Recurse through the nested object + else if ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + await loadSecretsFromFiles(value as Record, fullKeyPath); + } + } } type DeepPartial = diff --git a/app/server/config/schema.ts b/app/server/config/schema.ts index e45a687..7f7827b 100644 --- a/app/server/config/schema.ts +++ b/app/server/config/schema.ts @@ -1,45 +1,275 @@ -import { type } from 'arktype'; +import { Type, type } from 'arktype'; + +// Configuration Schema for Headplane +// +// For OIDC client secret, Headplane supports two ways to provide it: +// 1. Directly in the config file or environment variable (client_secret) +// 2. As a path to a file containing the secret (client_secret_path) +// +// Only one of these should be set. If client_secret_path is provided, +// Headplane will read the secret from that file during startup. + +/** + * Generates an ArkType schema for a field that can be provided either as a direct value + * or via a file path. Enforces mutual exclusivity and optional overall mandatoriness. + * + * @param key The base name for the field (e.g., "client_secret"). + * @param options Configuration options: + * - mandatory: If true, ensures either the direct value or the path is provided. + * - valueType: ArkType string for the direct value (e.g., "string", "32 <= string <= 32"). Defaults to "string". + * @returns An ArkType Type definition for the value/path pair. + */ +function valueOrPath( + key: Key, + options?: { mandatory?: boolean; valueType?: string }, +) { + const pathKey = `${key}_path` as const; + const valueTypeString = options?.valueType || 'string'; + + return type({ + [key]: valueTypeString, + [pathKey]: 'string', + }).narrow((obj: unknown, ctx) => { + if (typeof obj !== 'object' || obj === null) { + return ctx.reject('Expected an object'); + } + + const valProperty = (obj as Record)[key]; + const pathProperty = (obj as Record)[pathKey]; + + const hasVal = + valProperty !== undefined && + valProperty !== null && + (typeof valProperty === 'string' ? valProperty !== '' : true); + const hasPath = + pathProperty !== undefined && + pathProperty !== null && + typeof pathProperty === 'string' && + pathProperty !== ''; + + if (hasVal && hasPath) { + return ctx.reject(`Only one of "${key}" or "${pathKey}" may be set.`); + } + if (options?.mandatory && !hasVal && !hasPath) { + return ctx.reject( + `Either "${key}" or "${pathKey}" must be provided for ${key}.`, + ); + } + return true; + }); +} const stringToBool = type('string | boolean').pipe((v) => Boolean(v)); + +// --- Agent Config (defined separately for clarity in partial) --- +const agentObjectDefinition = type({ + 'authkey?': '(string | null)', + 'authkey_path?': '(string | null)', + ttl: 'number.integer = 180000', + cache_path: 'string = "/var/lib/headplane/agent_cache.json"', +}) + // biome-ignore lint/suspicious/noExplicitAny: ArkType context object + .narrow((obj, ctx: any) => { + const key = 'authkey'; + const pathKey = 'authkey_path'; + const valProperty = obj[key]; + const pathProperty = obj[pathKey]; + + const hasVal = + valProperty !== undefined && + valProperty !== null && + (typeof valProperty === 'string' ? valProperty !== '' : true); + const hasPath = + pathProperty !== undefined && + pathProperty !== null && + typeof pathProperty === 'string' && + pathProperty !== ''; + + console.log( + `Agent Narrow Check: authkey="${valProperty}", authkey_path="${pathProperty}", hasVal=${hasVal}, hasPath=${hasPath}`, + ); + + if (hasVal && hasPath) { + return ctx.reject( + `Only one of agent "${key}" or "${pathKey}" may be set.`, + ); + } + return true; + }) + .onDeepUndeclaredKey('reject'); + +const partialAgentConfig = type({ + 'authkey??': '(string | null)', + 'authkey_path??': '(string | null)', + 'ttl??': 'number.integer', + 'cache_path??': 'string', +}) + // biome-ignore lint/suspicious/noExplicitAny: ArkType context object + .narrow((obj, ctx: any) => { + const key = 'authkey?'; + const pathKey = 'authkey_path?'; + const valProperty = obj[key]; + const pathProperty = obj[pathKey]; + if (valProperty !== undefined && pathProperty !== undefined) { + const hasVal = + valProperty !== null && + (typeof valProperty === 'string' ? valProperty !== '' : true); + const hasPath = + pathProperty !== null && + typeof pathProperty === 'string' && + pathProperty !== ''; + if (hasVal && hasPath) { + return ctx.reject( + `Only one of agent "${key}" or "${pathKey}" may be set in partial config.`, + ); + } + } + return true; + }) + .onDeepUndeclaredKey('reject'); + +const agentConfig = agentObjectDefinition.default(() => ({ + authkey: null, + ttl: 180000, + cache_path: '/var/lib/headplane/agent_cache.json', +})); + +// --- Main Configurations --- const serverConfig = type({ host: 'string.ip', port: type('string | number.integer').pipe((v) => Number(v)), - cookie_secret: '32 <= string <= 32', + 'cookie_secret?': '((32 <= string <= 32) | null)', + 'cookie_secret_path?': 'string', cookie_secure: stringToBool, - agent: type({ - authkey: 'string = ""', - ttl: 'number.integer = 180000', // Default to 3 minutes - cache_path: 'string = "/var/lib/headplane/agent_cache.json"', - }) - .onDeepUndeclaredKey('reject') - .default(() => ({ - authkey: '', - ttl: 180000, - cache_path: '/var/lib/headplane/agent_cache.json', - })), + agent: agentConfig, +}).narrow((obj, ctx: any) => { + const key = 'cookie_secret'; + const pathKey = 'cookie_secret_path'; + const valProperty = obj[key]; + const pathProperty = obj[pathKey]; + + const hasVal = + valProperty !== undefined && + valProperty !== null && + (typeof valProperty === 'string' ? valProperty !== '' : true); + const hasPath = + pathProperty !== undefined && + pathProperty !== null && + typeof pathProperty === 'string' && + pathProperty !== ''; + + if (hasVal && hasPath) { + return ctx.reject(`Only one of "${key}" or "${pathKey}" may be set.`); + } + if (!hasVal && !hasPath) { + return ctx.reject( + `Either "${key}" or "${pathKey}" must be provided for ${key}.`, + ); + } + return true; }); const oidcConfig = type({ issuer: 'string.url', client_id: 'string', - client_secret: 'string?', - client_secret_path: 'string?', + 'client_secret?': '(string | null)', + 'client_secret_path?': 'string', + 'headscale_api_key?': '(string | null)', + 'headscale_api_key_path?': 'string', token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"', - redirect_uri: 'string.url?', - user_storage_file: 'string = "/var/lib/headplane/users.json"', + 'redirect_uri?': 'string.url', + user_storage_file: type('string').default('/var/lib/headplane/users.json'), disable_api_key_login: stringToBool, - headscale_api_key: 'string', strict_validation: stringToBool.default(true), -}).onDeepUndeclaredKey('reject'); +}) + .narrow((obj, ctx: any) => { + const clientSecretKey = 'client_secret'; + const clientSecretPathKey = 'client_secret_path'; + const clientSecretVal = obj[clientSecretKey]; + const clientSecretPathVal = obj[clientSecretPathKey]; + const hasClientSecretVal = + clientSecretVal !== undefined && + clientSecretVal !== null && + (typeof clientSecretVal === 'string' ? clientSecretVal !== '' : true); + const hasClientSecretPath = + clientSecretPathVal !== undefined && + clientSecretPathVal !== null && + typeof clientSecretPathVal === 'string' && + clientSecretPathVal !== ''; + + if (hasClientSecretVal && hasClientSecretPath) { + return ctx.reject( + `Only one of "${clientSecretKey}" or "${clientSecretPathKey}" may be set.`, + ); + } + if (obj.issuer && obj.client_id) { + if (!hasClientSecretVal && !hasClientSecretPath) { + return ctx.reject( + `Either "${clientSecretKey}" or "${clientSecretPathKey}" must be provided for client_secret if OIDC is configured.`, + ); + } + } + + const hsApiKey = 'headscale_api_key'; + const hsApiKeyPath = 'headscale_api_key_path'; + const hsApiVal = obj[hsApiKey]; + const hsApiPathVal = obj[hsApiKeyPath]; + const hasHsApiVal = + hsApiVal !== undefined && + hsApiVal !== null && + (typeof hsApiVal === 'string' ? hsApiVal !== '' : true); + const hasHsApiPath = + hsApiPathVal !== undefined && + hsApiPathVal !== null && + typeof hsApiPathVal === 'string' && + hsApiPathVal !== ''; + + if (hasHsApiVal && hasHsApiPath) { + return ctx.reject( + `Only one of "${hsApiKey}" or "${hsApiKeyPath}" may be set.`, + ); + } + if (obj.issuer && obj.client_id) { + if (!hasHsApiVal && !hasHsApiPath) { + return ctx.reject( + `Either "${hsApiKey}" or "${hsApiKeyPath}" must be provided for oidc.headscale_api_key if OIDC is configured.`, + ); + } + } + return true; + }) + .onDeepUndeclaredKey('reject'); const headscaleConfig = type({ url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)), - tls_cert_path: 'string?', - public_url: 'string.url?', - config_path: 'string?', + 'api_key?': 'string', + 'api_key_path?': 'string', + 'tls_cert_path?': '(string | null)', + 'public_url?': 'string.url', + 'config_path?': 'string', config_strict: stringToBool, -}).onDeepUndeclaredKey('reject'); +}) + .narrow((obj, ctx: any) => { + const key = 'api_key'; + const pathKey = 'api_key_path'; + const valProperty = obj[key]; + const pathProperty = obj[pathKey]; + const hasVal = + valProperty !== undefined && + valProperty !== null && + (typeof valProperty === 'string' ? valProperty !== '' : true); + const hasPath = + pathProperty !== undefined && + pathProperty !== null && + typeof pathProperty === 'string' && + pathProperty !== ''; + if (hasVal && hasPath) { + return ctx.reject(`Only one of "${key}" or "${pathKey}" may be set.`); + } + return true; + }) + .onDeepUndeclaredKey('reject'); const containerLabel = type({ name: 'string', @@ -69,26 +299,169 @@ const integrationConfig = type({ 'proc?': procConfig, }).onDeepUndeclaredKey('reject'); -const partialIntegrationConfig = type({ - 'docker?': dockerConfig.partial(), - 'kubernetes?': kubernetesConfig.partial(), - 'proc?': procConfig.partial(), -}).partial(); - export const headplaneConfig = type({ - debug: stringToBool, + debug: stringToBool.default(false), server: serverConfig, 'oidc?': oidcConfig, 'integration?': integrationConfig, headscale: headscaleConfig, }).onDeepUndeclaredKey('delete'); +// --- Partial Configurations (Explicitly defined field by field) --- + +const partialServerConfig = type({ + 'host?': 'string.ip', + 'port?': type('string | number.integer').pipe((v) => Number(v)), + 'cookie_secret?': '((32 <= string <= 32) | null)', + 'cookie_secret_path?': 'string', + 'cookie_secure?': stringToBool, + 'agent?': partialAgentConfig, +}).narrow((obj, ctx: any) => { + const key = 'cookie_secret'; + const pathKey = 'cookie_secret_path'; + const valProperty = obj[key]; + const pathProperty = obj[pathKey]; + const hasVal = + valProperty !== undefined && + valProperty !== null && + (typeof valProperty === 'string' ? valProperty !== '' : true); + const hasPath = + pathProperty !== undefined && + pathProperty !== null && + typeof pathProperty === 'string' && + pathProperty !== ''; + if (hasVal && hasPath) { + return ctx.reject(`Only one of "${key}" or "${pathKey}" may be set.`); + } + if (Object.keys(obj).length > 0 && !obj.agent && !obj.cookie_secure) { + if (!hasVal && !hasPath) { + return ctx.reject( + `Either "${key}" or "${pathKey}" must be provided for cookie_secret if server section is present.`, + ); + } + } + return true; +}); + +const partialOidcConfig = type({ + 'issuer?': 'string.url', + 'client_id?': 'string', + 'client_secret?': '(string | null)', + 'client_secret_path?': 'string', + 'token_endpoint_auth_method?': + '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"', + 'redirect_uri?': 'string.url', + 'user_storage_file?': 'string', + 'disable_api_key_login?': stringToBool, + 'headscale_api_key?': '(string | null)', + 'headscale_api_key_path?': 'string', + 'strict_validation?': stringToBool, +}) + .narrow((obj, ctx: any) => { + const clientSecretKey = 'client_secret'; + const clientSecretPathKey = 'client_secret_path'; + const clientSecretVal = obj[clientSecretKey]; + const clientSecretPathVal = obj[clientSecretPathKey]; + const hasClientSecretVal = + clientSecretVal !== undefined && + clientSecretVal !== null && + (typeof clientSecretVal === 'string' ? clientSecretVal !== '' : true); + const hasClientSecretPath = + clientSecretPathVal !== undefined && + clientSecretPathVal !== null && + typeof clientSecretPathVal === 'string' && + clientSecretPathVal !== ''; + if (hasClientSecretVal && hasClientSecretPath) { + return ctx.reject( + `Only one of "${clientSecretKey}" or "${clientSecretPathKey}" may be set.`, + ); + } + if ( + obj.issuer && + obj.client_id && + !hasClientSecretVal && + !hasClientSecretPath + ) { + return ctx.reject( + `Either "${clientSecretKey}" or "${clientSecretPathKey}" must be provided if issuer and client_id are set in partial OIDC config.`, + ); + } + + const hsApiKey = 'headscale_api_key'; + const hsApiKeyPath = 'headscale_api_key_path'; + const hsApiVal = obj[hsApiKey]; + const hsApiPathVal = obj[hsApiKeyPath]; + const hasHsApiVal = + hsApiVal !== undefined && + hsApiVal !== null && + (typeof hsApiVal === 'string' ? hsApiVal !== '' : true); + const hasHsApiPath = + hsApiPathVal !== undefined && + hsApiPathVal !== null && + typeof hsApiPathVal === 'string' && + hsApiPathVal !== ''; + if (hasHsApiVal && hasHsApiPath) { + return ctx.reject( + `Only one of "${hsApiKey}" or "${hsApiKeyPath}" may be set.`, + ); + } + if (obj.issuer && obj.client_id && !hasHsApiVal && !hasHsApiPath) { + return ctx.reject( + `Either "${hsApiKey}" or "${hsApiKeyPath}" must be provided for headscale_api_key if issuer and client_id are set in partial OIDC config.`, + ); + } + return true; + }) + .onDeepUndeclaredKey('reject'); + +const partialHeadscaleConfig = type({ + 'url?': type('string.url').pipe((v) => + v.endsWith('/') ? v.slice(0, -1) : v, + ), + 'api_key?': 'string', + 'api_key_path?': 'string', + 'tls_cert_path?': '(string | null)', + 'public_url?': 'string.url', + 'config_path?': 'string', + 'config_strict?': stringToBool, +}) + .narrow((obj, ctx: any) => { + const key = 'api_key'; + const pathKey = 'api_key_path'; + const valProperty = obj[key]; + const pathProperty = obj[pathKey]; + const hasVal = + valProperty !== undefined && + valProperty !== null && + (typeof valProperty === 'string' ? valProperty !== '' : true); + const hasPath = + pathProperty !== undefined && + pathProperty !== null && + typeof pathProperty === 'string' && + pathProperty !== ''; + if (hasVal && hasPath) { + return ctx.reject(`Only one of "${key}" or "${pathKey}" may be set.`); + } + return true; + }) + .onDeepUndeclaredKey('reject'); + +const partialDockerConfig = dockerConfig.partial(); +const partialKubernetesConfig = kubernetesConfig.partial(); +const partialProcConfig = procConfig.partial(); + +const partialIntegrationConfig = type({ + 'docker?': partialDockerConfig, + 'kubernetes?': partialKubernetesConfig, + 'proc?': partialProcConfig, +}).partial(); + export const partialHeadplaneConfig = type({ - debug: stringToBool, - server: serverConfig.partial(), - 'oidc?': oidcConfig.partial(), - 'integration?': partialIntegrationConfig, - headscale: headscaleConfig.partial(), + 'debug?': stringToBool, + 'server?': partialServerConfig, + 'oidc?': partialOidcConfig, + 'integration?': integrationConfig.partial(), + 'headscale?': partialHeadscaleConfig, }).partial(); export type HeadplaneConfig = typeof headplaneConfig.infer; diff --git a/app/server/web/agent.ts b/app/server/web/agent.ts index 62c1f0c..e6d191a 100644 --- a/app/server/web/agent.ts +++ b/app/server/web/agent.ts @@ -10,12 +10,16 @@ import { HostInfo } from '~/types'; import log from '~/utils/log'; export async function loadAgentSocket( - authkey: string, + authkey: string | null, path: string, ttl: number, ) { - if (authkey.length === 0) { - return; + if (authkey === null || authkey.length === 0) { + log.warn( + 'agent', + 'Agent authkey is not configured or is empty, agent support will be disabled.', + ); + return undefined; } try { diff --git a/biome.json b/biome.json index 2092ced..95b77d1 100644 --- a/biome.json +++ b/biome.json @@ -28,6 +28,9 @@ }, "correctness": { "useExhaustiveDependencies": "off" + }, + "suspicious": { + "noExplicitAny": "warn" } } }, diff --git a/docs/Configuration.md b/docs/Configuration.md index 0c3770b..9ea2ce1 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -11,6 +11,41 @@ Headplane also stores stuff in the `/var/lib/headplane` directory by default. This can be configured on a per-section basis in the configuration file, but it is very important this directory is persistent and writable by Headplane. +## Sensitive Values +Headplane supports a dual-mode pattern for providing certain sensitive configuration values, such as secrets, keys, and private certificates. For each such field, you can either: + +1. Set the value directly in the configuration file (e.g., `cookie_secret: "your-32-character-long-secret"`) +2. Provide a path to a file containing the value using the `_path` suffixed key (e.g., `cookie_secret_path: "/path/to/your/cookie_secret_file"`) + +When using a `_path` option, the content of the specified file will be read and used as the value for the setting. These paths can include environment variable interpolation (e.g., `${CREDENTIALS_DIRECTORY}/my_secret_file`), which is useful for integration with tools like systemd's `LoadCredential`. + +**Important Rules for Dual-Mode Fields:** +- You **cannot** set both the direct value (e.g., `cookie_secret`) and its corresponding `_path` (e.g., `cookie_secret_path`) simultaneously. Doing so will result in a configuration error. +- If a `_path` is provided, the corresponding direct value field (if also present and not null) will usually be ignored or may cause validation errors depending on the specific field and loader logic. It's best to provide only one. + +The following configuration options in Headplane currently support this dual value/path mode: + +- **Server Settings (`server.*`):** + - `cookie_secret` / `cookie_secret_path` (for web session encoding) + - `agent.authkey` / `agent.authkey_path` (for the server's internal agent functionality) + - *Note:* These are optional. If neither `authkey` nor `authkey_path` are provided for the server's internal agent, or if they resolve to null/empty, Headplane will log a message indicating that its internal agent support features are disabled and will proceed without error. This is the default behavior if the `server.agent` block is empty or not explicitly configured. + +- **Headscale Connection Settings (`headscale.*`):** + - `tls_cert` / `tls_cert_path` (custom TLS certificate for connecting to Headscale) + - `tls_key` / `tls_key_path` (custom TLS private key for connecting to Headscale) + - `api_key` / `api_key_path` (Headscale API key for Headplane to connect to Headscale - *Note: Please verify if this specific Headscale API key is managed by Headplane's config or directly by Headscale's own config and how it's used by Headplane.*) + +- **OIDC Settings (`oidc.*`):** + - `client_secret` / `client_secret_path` (OIDC client secret) + - `headscale_api_key` / `headscale_api_key_path` (Headscale API key used by Headplane during the OIDC authentication flow) + +**Distinction for Non-Secret Paths:** +It's important to distinguish the `_path` fields above (which point to files whose *content* is the secret value) from other configuration fields that are also paths but serve different purposes. For example: +- `server.agent.cache_path` (default: `/var/lib/headplane/agent_cache.json`): This is a direct file path where Headplane will *store* its agent cache data. It is not a path to a file containing a secret. +- `headscale.config_path`: This is an optional path to Headscale's `config.yaml` file, which Headplane might read (if `config_strict: false`) or use for validation. + +These types of paths are also subject to environment variable interpolation if applicable. + ## Environment Variables It is also possible to override the configuration file using environment variables. These changes get merged *after* the configuration file is loaded, so they will take precedence. diff --git a/nix/package.nix b/nix/package.nix index 88a9cf4..8df39a6 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -23,7 +23,7 @@ stdenv.mkDerivation (finalAttrs: { pnpmDeps = pnpm_10.fetchDeps { inherit (finalAttrs) pname version src; - hash = "sha256-GtpwQz7ngLpP+BubH6uaG1uUZsZdCQzvTI1WKBYU2T4="; + hash = "sha256-GEtnKC8UcsudQNVAvO4aPpYaECu2dBTcfSp2m+RnBgc="; }; buildPhase = '' diff --git a/package.json b/package.json index 9eb9865..26f25ab 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "build": "react-router build", "dev": "HEADPLANE_LOAD_ENV_OVERRIDES=true HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev", "start": "node build/server/index.js", - "typecheck": "tsc" + "typecheck": "tsc", + "test": "vitest run" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -29,7 +30,7 @@ "@uiw/codemirror-theme-github": "^4.23.7", "@uiw/codemirror-theme-xcode": "^4.23.8", "@uiw/react-codemirror": "^4.23.7", - "arktype": "^2.0.4", + "arktype": "^2.1.20", "clsx": "^2.1.1", "dotenv": "^16.4.7", "isbot": "^5.1.19", @@ -57,10 +58,12 @@ "@babel/preset-typescript": "^7.26.0", "@biomejs/biome": "^1.9.4", "@react-router/dev": "^7.4.0", + "@types/node": "^22.15.17", "@types/websocket": "^1.0.10", "@types/ws": "^8.5.13", "autoprefixer": "^10.4.21", "babel-plugin-react-compiler": "19.0.0-beta-55955c9-20241229", + "js-yaml": "^4.1.0", "lefthook": "^1.10.9", "postcss": "^8.5.3", "react-router-dom": "^7.4.0", @@ -72,7 +75,8 @@ "vite": "^6.2.2", "vite-node": "^3.0.8", "vite-plugin-babel": "^1.3.0", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.1.3" }, "packageManager": "pnpm@10.4.0", "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4ca1fe..dc2e96a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,8 +68,8 @@ importers: specifier: ^4.23.7 version: 4.23.7(@babel/runtime@7.26.0)(@codemirror/autocomplete@6.18.2(@codemirror/language@6.10.8)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)(@lezer/common@1.2.3))(@codemirror/language@6.10.8)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.5.0)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.36.1)(codemirror@6.0.1(@lezer/common@1.2.3))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) arktype: - specifier: ^2.0.4 - version: 2.0.4 + specifier: ^2.1.20 + version: 2.1.20 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -111,7 +111,7 @@ importers: version: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-router-hono-server: specifier: ^2.11.0 - version: 2.11.0(patch_hash=c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e)(@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)) + version: 2.11.0(patch_hash=c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e)(@react-router/dev@7.4.0(@types/node@22.15.17)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)) react-stately: specifier: ^3.35.0 version: 3.35.0(react@19.0.0) @@ -145,7 +145,10 @@ importers: version: 1.9.4 '@react-router/dev': specifier: ^7.4.0 - version: 7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0) + version: 7.4.0(@types/node@22.15.17)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0) + '@types/node': + specifier: ^22.15.17 + version: 22.15.17 '@types/websocket': specifier: ^1.0.10 version: 1.0.10 @@ -158,6 +161,9 @@ importers: babel-plugin-react-compiler: specifier: 19.0.0-beta-55955c9-20241229 version: 19.0.0-beta-55955c9-20241229 + js-yaml: + specifier: ^4.1.0 + version: 4.1.0 lefthook: specifier: ^1.10.9 version: 1.10.9 @@ -172,28 +178,31 @@ importers: version: 0.1.0(react-dom@19.0.0(react@19.0.0))(react-router-dom@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(rollup@4.36.0) tailwindcss: specifier: ^3.4.17 - version: 3.4.17 + version: 3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.2)) tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.17) + version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.2))) tailwindcss-react-aria-components: specifier: ^2.0.0 - version: 2.0.0(tailwindcss@3.4.17) + version: 2.0.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.2))) typescript: specifier: ^5.8.2 version: 5.8.2 vite: specifier: ^6.2.2 - version: 6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) + version: 6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) vite-node: specifier: ^3.0.8 - version: 3.0.8(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) + version: 3.0.8(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) vite-plugin-babel: specifier: ^1.3.0 - version: 1.3.0(@babel/core@7.26.0)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)) + version: 1.3.0(@babel/core@7.26.0)(vite@6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)) + version: 5.1.4(typescript@5.8.2)(vite@6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)) + vitest: + specifier: ^3.1.3 + version: 3.1.3(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) packages: @@ -205,11 +214,11 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@ark/schema@0.39.0': - resolution: {integrity: sha512-LQbQUb3Sj461LgklXObAyUJNtsUUCBxZlO2HqRLYvRSqpStm0xTMrXn51DwBNNxeSULvKVpXFwoxiSec9kwKww==} + '@ark/schema@0.46.0': + resolution: {integrity: sha512-c2UQdKgP2eqqDArfBqQIJppxJHvNNXuQPeuSPlDML4rjw+f1cu0qAlzOG4b8ujgm9ctIDWwhpyw6gjG5ledIVQ==} - '@ark/util@0.39.0': - resolution: {integrity: sha512-90APHVklk8BP4kku7hIh1BgrhuyKYqoZ4O7EybtFRo7cDl9mIyc/QUbGvYDg//73s0J2H0I/gW9pzroA1R4IBQ==} + '@ark/util@0.46.0': + resolution: {integrity: sha512-JPy/NGWn/lvf1WmGCPw2VGpBg5utZraE84I7wli18EDF3p3zc/e9WolT35tINeZO3l7C77SjqRJeAUoT0CvMRg==} '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} @@ -481,6 +490,10 @@ packages: '@codemirror/view@6.36.1': resolution: {integrity: sha512-miD1nyT4m4uopZaDdO2uXU/LLHliKNYL9kB1C1wJHrunHLm/rpkb5QVSokqgw9hFqEZakrdlb/VGWX8aYZTslQ==} + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1051,6 +1064,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@jsep-plugin/assignment@1.3.0': resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==} engines: {node: '>= 10.16.0'} @@ -1817,6 +1833,18 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -1826,11 +1854,8 @@ packages: '@types/node@20.17.16': resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==} - '@types/node@22.10.1': - resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==} - - '@types/node@22.10.7': - resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==} + '@types/node@22.15.17': + resolution: {integrity: sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==} '@types/react-dom@19.0.2': resolution: {integrity: sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==} @@ -1888,6 +1913,39 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + '@vitest/expect@3.1.3': + resolution: {integrity: sha512-7FTQQuuLKmN1Ig/h+h/GO+44Q1IlglPlR2es4ab7Yvfx+Uk5xsv+Ykk+MEt/M2Yn/xGmzaLKxGw2lgy2bwuYqg==} + + '@vitest/mocker@3.1.3': + resolution: {integrity: sha512-PJbLjonJK82uCWHjzgBJZuR7zmAOrSvKk1QBxrennDIgtH4uK0TB1PvYmc0XBCigxxtiAVPfWtAdy4lpz8SQGQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.1.3': + resolution: {integrity: sha512-i6FDiBeJUGLDKADw2Gb01UtUNb12yyXAqC/mmRWuYl+m/U9GS7s8us5ONmGkGpUUo7/iAYzI2ePVfOZTYvUifA==} + + '@vitest/runner@3.1.3': + resolution: {integrity: sha512-Tae+ogtlNfFei5DggOsSUvkIaSuVywujMj6HzR97AHK6XK8i3BuVyIifWAm/sE3a15lF5RH9yQIrbXYuo0IFyA==} + + '@vitest/snapshot@3.1.3': + resolution: {integrity: sha512-XVa5OPNTYUsyqG9skuUkFzAeFnEzDp8hQu7kZ0N25B1+6KjGm4hWLtURyBbsIAOekfWQ7Wuz/N/XXzgYO3deWQ==} + + '@vitest/spy@3.1.3': + resolution: {integrity: sha512-x6w+ctOEmEXdWaa6TO4ilb7l9DxPR5bwEb6hILKuxfU1NqWT2mpJD9NJN7t3OTfxmVlOMrvtoFJGdgyzZ605lQ==} + + '@vitest/utils@3.1.3': + resolution: {integrity: sha512-2Ltrpht4OmHO9+c/nmHtF09HWiyWdworqnHIwjfvDyWjuwKbdkcS9AnhsDn+8E2RM4x++foD1/tNuLPVvWG1Rg==} + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + acorn@8.14.0: resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} engines: {node: '>=0.4.0'} @@ -1924,14 +1982,17 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - arktype@2.0.4: - resolution: {integrity: sha512-S68rWVDnJauwH7/QCm8zCUM3aTe9Xk6oRihdcc3FSUAtxCo/q1Fwq46JhcwB5Ufv1YStwdQRz+00Y/URlvbhAQ==} + arktype@2.1.20: + resolution: {integrity: sha512-IZCEEXaJ8g+Ijd59WtSYwtjnqXiwM8sWQ5EjGamcto7+HVN9eK0C4p0zDlCuAwWhpqr6fIBkxPuYDl4/Mcj/+Q==} asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -1940,6 +2001,10 @@ packages: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2020,6 +2085,14 @@ packages: caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + chai@5.2.0: + resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} + engines: {node: '>=12'} + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -2067,6 +2140,9 @@ packages: core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -2103,6 +2179,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2110,6 +2190,10 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -2141,6 +2225,9 @@ packages: es-module-lexer@1.6.0: resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.23.1: resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==} engines: {node: '>=18'} @@ -2170,6 +2257,10 @@ packages: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} + expect-type@1.2.1: + resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -2190,6 +2281,14 @@ packages: fastq@1.18.0: resolution: {integrity: sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==} + fdir@6.4.4: + resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2457,6 +2556,9 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + loupe@3.1.3: + resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} + lru-cache@10.2.2: resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==} engines: {node: 14 || >=16.14} @@ -2473,6 +2575,12 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2601,6 +2709,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} + performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} @@ -2920,6 +3032,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2955,6 +3070,12 @@ packages: engines: {node: '>=0.10.0'} hasBin: true + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + stream-buffers@3.0.3: resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} engines: {node: '>= 0.10.0'} @@ -3024,6 +3145,28 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.13: + resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} + engines: {node: '>=12.0.0'} + + tinypool@1.0.2: + resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3035,6 +3178,20 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + tsconfck@3.1.4: resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==} engines: {node: ^18 || >=20} @@ -3077,8 +3234,8 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} - undici-types@6.20.0: - resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} undici@6.21.2: resolution: {integrity: sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g==} @@ -3134,6 +3291,9 @@ packages: deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. hasBin: true + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + valibot@0.41.0: resolution: {integrity: sha512-igDBb8CTYr8YTQlOKgaN9nSS0Be7z+WRuaeYqGf3Cjz3aKmSnqEmYnkfVjzIuumGqfHpa3fLIvMEAfhrpqN8ng==} peerDependencies: @@ -3163,6 +3323,11 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-node@3.1.3: + resolution: {integrity: sha512-uHV4plJ2IxCl4u1up1FQRrqclylKAogbtBfOTwcuJ28xFi+89PZ57BRh+naIRvH70HPwxy5QHYzg1OrEaC7AbA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-plugin-babel@1.3.0: resolution: {integrity: sha512-C5WKX0UwvQKH8WD2GiyWUjI62UBfLbfUhiLexnIm4asLdENX5ymrRipFlBnGeVxoOaYgTL5dh5KW6YDGpWsR8A==} peerDependencies: @@ -3217,6 +3382,34 @@ packages: yaml: optional: true + vitest@3.1.3: + resolution: {integrity: sha512-188iM4hAHQ0km23TN/adso1q5hhwKqUpv+Sd6p5sOuh6FhQnRNW3IsiIpvxqahtBabsJ2SLZgmGSpcYK4wQYJw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.1.3 + '@vitest/ui': 3.1.3 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -3233,6 +3426,11 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3265,6 +3463,10 @@ packages: engines: {node: '>= 14'} hasBin: true + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + zod@3.24.1: resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} @@ -3277,11 +3479,11 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - '@ark/schema@0.39.0': + '@ark/schema@0.46.0': dependencies: - '@ark/util': 0.39.0 + '@ark/util': 0.46.0 - '@ark/util@0.39.0': {} + '@ark/util@0.46.0': {} '@babel/code-frame@7.26.2': dependencies: @@ -3732,6 +3934,11 @@ snapshots: style-mod: 4.1.2 w3c-keyname: 2.2.8 + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + optional: true + '@dnd-kit/accessibility@3.1.1(react@19.0.0)': dependencies: react: 19.0.0 @@ -4099,6 +4306,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + optional: true + '@jsep-plugin/assignment@1.3.0(jsep@1.4.0)': dependencies: jsep: 1.4.0 @@ -4803,7 +5016,7 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - '@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0)': + '@react-router/dev@7.4.0(@types/node@22.15.17)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0)': dependencies: '@babel/core': 7.26.10 '@babel/generator': 7.26.10 @@ -4832,8 +5045,8 @@ snapshots: semver: 7.7.1 set-cookie-parser: 2.7.1 valibot: 0.41.0(typescript@5.8.2) - vite: 6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) - vite-node: 3.0.0-beta.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) + vite-node: 3.0.0-beta.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) optionalDependencies: typescript: 5.8.2 transitivePeerDependencies: @@ -5317,6 +5530,18 @@ snapshots: dependencies: tslib: 2.8.1 + '@tsconfig/node10@1.0.11': + optional: true + + '@tsconfig/node12@1.0.11': + optional: true + + '@tsconfig/node14@1.0.3': + optional: true + + '@tsconfig/node16@1.0.4': + optional: true + '@types/cookie@0.6.0': {} '@types/estree@1.0.6': {} @@ -5325,13 +5550,9 @@ snapshots: dependencies: undici-types: 6.19.8 - '@types/node@22.10.1': + '@types/node@22.15.17': dependencies: - undici-types: 6.20.0 - - '@types/node@22.10.7': - dependencies: - undici-types: 6.20.0 + undici-types: 6.21.0 '@types/react-dom@19.0.2(@types/react@19.0.2)': dependencies: @@ -5343,11 +5564,11 @@ snapshots: '@types/websocket@1.0.10': dependencies: - '@types/node': 22.10.7 + '@types/node': 22.15.17 '@types/ws@8.5.13': dependencies: - '@types/node': 22.10.1 + '@types/node': 22.15.17 '@uiw/codemirror-extensions-basic-setup@4.23.7(@codemirror/autocomplete@6.18.2(@codemirror/language@6.10.8)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)(@lezer/common@1.2.3))(@codemirror/commands@6.7.1)(@codemirror/language@6.10.8)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)': dependencies: @@ -5404,6 +5625,51 @@ snapshots: - '@codemirror/lint' - '@codemirror/search' + '@vitest/expect@3.1.3': + dependencies: + '@vitest/spy': 3.1.3 + '@vitest/utils': 3.1.3 + chai: 5.2.0 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.1.3(vite@6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))': + dependencies: + '@vitest/spy': 3.1.3 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) + + '@vitest/pretty-format@3.1.3': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.1.3': + dependencies: + '@vitest/utils': 3.1.3 + pathe: 2.0.3 + + '@vitest/snapshot@3.1.3': + dependencies: + '@vitest/pretty-format': 3.1.3 + magic-string: 0.30.17 + pathe: 2.0.3 + + '@vitest/spy@3.1.3': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@3.1.3': + dependencies: + '@vitest/pretty-format': 3.1.3 + loupe: 3.1.3 + tinyrainbow: 2.0.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.14.1 + optional: true + acorn@8.14.0: optional: true @@ -5434,14 +5700,17 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + arg@4.1.3: + optional: true + arg@5.0.2: {} argparse@2.0.1: {} - arktype@2.0.4: + arktype@2.1.20: dependencies: - '@ark/schema': 0.39.0 - '@ark/util': 0.39.0 + '@ark/schema': 0.46.0 + '@ark/util': 0.46.0 asn1@0.2.6: dependencies: @@ -5449,6 +5718,8 @@ snapshots: assert-plus@1.0.0: {} + assertion-error@2.0.1: {} + asynckit@0.4.0: {} autoprefixer@10.4.21(postcss@8.5.3): @@ -5529,6 +5800,16 @@ snapshots: caseless@0.12.0: {} + chai@5.2.0: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.3 + pathval: 2.0.0 + + check-error@2.1.1: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -5582,6 +5863,9 @@ snapshots: core-util-is@1.0.2: {} + create-require@1.1.1: + optional: true + crelt@1.0.6: {} cross-spawn@7.0.3: @@ -5604,10 +5888,15 @@ snapshots: dedent@1.5.3: {} + deep-eql@5.0.2: {} + delayed-stream@1.0.0: {} didyoumean@1.2.2: {} + diff@4.0.2: + optional: true + dlv@1.1.3: {} dotenv@16.4.7: {} @@ -5631,6 +5920,8 @@ snapshots: es-module-lexer@1.6.0: {} + es-module-lexer@1.7.0: {} + esbuild@0.23.1: optionalDependencies: '@esbuild/aix-ppc64': 0.23.1 @@ -5724,6 +6015,8 @@ snapshots: exit-hook@2.2.1: {} + expect-type@1.2.1: {} + extend@3.0.2: {} extsprintf@1.3.0: {} @@ -5744,6 +6037,10 @@ snapshots: dependencies: reusify: 1.0.4 + fdir@6.4.4(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -5974,6 +6271,8 @@ snapshots: lodash@4.17.21: {} + loupe@3.1.3: {} + lru-cache@10.2.2: {} lru-cache@5.1.1: @@ -5986,6 +6285,13 @@ snapshots: dependencies: react: 19.0.0 + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + make-error@1.3.6: + optional: true + merge2@1.4.1: {} micromatch@4.0.8: @@ -6092,6 +6398,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.0: {} + performance-now@2.1.0: {} picocolors@1.1.1: {} @@ -6124,12 +6432,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.3 - postcss-load-config@4.0.2(postcss@8.5.3): + postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.2)): dependencies: lilconfig: 3.1.3 yaml: 2.7.0 optionalDependencies: postcss: 8.5.3 + ts-node: 10.9.2(@types/node@22.15.17)(typescript@5.8.2) postcss-nested@6.2.0(postcss@8.5.3): dependencies: @@ -6255,17 +6564,17 @@ snapshots: react-dom: 19.0.0(react@19.0.0) react-router: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - react-router-hono-server@2.11.0(patch_hash=c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e)(@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)): + react-router-hono-server@2.11.0(patch_hash=c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e)(@react-router/dev@7.4.0(@types/node@22.15.17)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)): dependencies: '@drizzle-team/brocli': 0.11.0 '@hono/node-server': 1.14.0(hono@4.7.5) '@hono/node-ws': 1.1.0(@hono/node-server@1.14.0(hono@4.7.5))(bufferutil@4.0.9)(hono@4.7.5)(utf-8-validate@5.0.10) '@hono/vite-dev-server': 0.17.0(hono@4.7.5) - '@react-router/dev': 7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0) + '@react-router/dev': 7.4.0(@types/node@22.15.17)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0) '@types/react': 19.0.2 hono: 4.7.5 react-router: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - vite: 6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -6447,6 +6756,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} sisteransi@1.0.5: {} @@ -6486,6 +6797,10 @@ snapshots: safer-buffer: 2.1.2 tweetnacl: 0.14.5 + stackback@0.0.2: {} + + std-env@3.9.0: {} + stream-buffers@3.0.3: {} stream-slice@0.1.2: {} @@ -6526,15 +6841,15 @@ snapshots: tailwind-merge@2.6.0: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.17): + tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.2))): dependencies: - tailwindcss: 3.4.17 + tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.2)) - tailwindcss-react-aria-components@2.0.0(tailwindcss@3.4.17): + tailwindcss-react-aria-components@2.0.0(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.2))): dependencies: - tailwindcss: 3.4.17 + tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.2)) - tailwindcss@3.4.17: + tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.2)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -6553,7 +6868,7 @@ snapshots: postcss: 8.5.3 postcss-import: 15.1.0(postcss@8.5.3) postcss-js: 4.0.1(postcss@8.5.3) - postcss-load-config: 4.0.2(postcss@8.5.3) + postcss-load-config: 4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.2)) postcss-nested: 6.2.0(postcss@8.5.3) postcss-selector-parser: 6.1.2 resolve: 1.22.10 @@ -6586,6 +6901,21 @@ snapshots: dependencies: any-promise: 1.3.0 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.13: + dependencies: + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + + tinypool@1.0.2: {} + + tinyrainbow@2.0.0: {} + + tinyspy@3.0.2: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -6597,6 +6927,25 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-node@10.9.2(@types/node@22.15.17)(typescript@5.8.2): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.15.17 + acorn: 8.14.1 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.8.2 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optional: true + tsconfck@3.1.4(typescript@5.8.2): optionalDependencies: typescript: 5.8.2 @@ -6626,7 +6975,7 @@ snapshots: undici-types@6.19.8: {} - undici-types@6.20.0: {} + undici-types@6.21.0: {} undici@6.21.2: {} @@ -6674,6 +7023,9 @@ snapshots: uuid@3.4.0: {} + v8-compile-cache-lib@3.0.1: + optional: true + valibot@0.41.0(typescript@5.8.2): optionalDependencies: typescript: 5.8.2 @@ -6691,13 +7043,13 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 - vite-node@3.0.0-beta.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0): + vite-node@3.0.0-beta.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0): dependencies: cac: 6.7.14 debug: 4.4.0 es-module-lexer: 1.6.0 pathe: 1.1.2 - vite: 6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - '@types/node' - jiti @@ -6712,13 +7064,13 @@ snapshots: - tsx - yaml - vite-node@3.0.8(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0): + vite-node@3.0.8(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0): dependencies: cac: 6.7.14 debug: 4.4.0 es-module-lexer: 1.6.0 pathe: 2.0.3 - vite: 6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - '@types/node' - jiti @@ -6733,35 +7085,95 @@ snapshots: - tsx - yaml - vite-plugin-babel@1.3.0(@babel/core@7.26.0)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)): + vite-node@3.1.3(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0): + dependencies: + cac: 6.7.14 + debug: 4.4.0 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-plugin-babel@1.3.0(@babel/core@7.26.0)(vite@6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)): dependencies: '@babel/core': 7.26.0 - vite: 6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) - vite-tsconfig-paths@5.1.4(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)): + vite-tsconfig-paths@5.1.4(typescript@5.8.2)(vite@6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)): dependencies: debug: 4.4.0 globrex: 0.1.2 tsconfck: 3.1.4(typescript@5.8.2) optionalDependencies: - vite: 6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) + vite: 6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: - supports-color - typescript - vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0): + vite@6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0): dependencies: esbuild: 0.25.1 postcss: 8.5.3 rollup: 4.36.0 optionalDependencies: - '@types/node': 22.10.7 + '@types/node': 22.15.17 fsevents: 2.3.3 jiti: 1.21.7 terser: 5.39.0 tsx: 4.19.2 yaml: 2.7.0 + vitest@3.1.3(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0): + dependencies: + '@vitest/expect': 3.1.3 + '@vitest/mocker': 3.1.3(vite@6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)) + '@vitest/pretty-format': 3.1.3 + '@vitest/runner': 3.1.3 + '@vitest/snapshot': 3.1.3 + '@vitest/spy': 3.1.3 + '@vitest/utils': 3.1.3 + chai: 5.2.0 + debug: 4.4.0 + expect-type: 1.2.1 + magic-string: 0.30.17 + pathe: 2.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.13 + tinypool: 1.0.2 + tinyrainbow: 2.0.0 + vite: 6.2.2(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) + vite-node: 3.1.3(@types/node@22.15.17)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.15.17 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + w3c-keyname@2.2.8: {} webpack-virtual-modules@0.6.2: @@ -6775,6 +7187,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -6798,4 +7215,7 @@ snapshots: yaml@2.7.0: {} + yn@3.1.1: + optional: true + zod@3.24.1: {} diff --git a/tests/config.test.js b/tests/config.test.js new file mode 100644 index 0000000..4612a31 --- /dev/null +++ b/tests/config.test.js @@ -0,0 +1,586 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +// Get the directory name in ESM +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Helper to create a temp file with given content, return its path +// Note: This helper does not automatically clean up created files/directories. +function writeTempFile(baseName, content) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'headplane-test-')); + const filePath = path.join(dir, baseName); + fs.writeFileSync(filePath, content); + return filePath; +} + +// Helper to create a temporary YAML config file with minimal required structure +function writeTempYamlConfig(customConfig = {}) { + const defaultConfig = { + debug: false, + server: { + host: '127.0.0.1', + port: 8080, + cookie_secret: '12345678901234567890123456789012', // Exactly 32 chars + cookie_secure: false, + agent: { + authkey: 'testagentauthkey', + ttl: 180000, + cache_path: '/tmp/agent_cache.json', + }, + }, + headscale: { + url: 'http://localhost:8081', // Required! + config_strict: false, + // api_key or api_key_path might be provided by specific tests or be absent + }, + // OIDC is optional at the top level, so it's not in defaultConfig by default. + // Tests requiring OIDC will add the oidc block via customConfig. + }; + + // Deep merge customConfig into defaultConfig + function deepMerge(target, source) { + let output = { ...target }; + if (isObject(target) && isObject(source)) { + output = { ...target, ...source }; + + for (const key of Object.keys(source)) { + if (isObject(source[key]) && key in target && isObject(target[key])) { + output[key] = deepMerge(target[key], source[key]); + } else { + output[key] = source[key]; + } + } + + const sections = ['oidc', 'server', 'headscale']; + for (const sectionName of sections) { + if (output[sectionName] && source[sectionName]) { + const sectionSource = source[sectionName]; + const sectionDefault = target[sectionName] || {}; + const sectionOutput = output[sectionName]; + + for (const baseKey of ['client_secret', 'cookie_secret', 'api_key']) { + const valueKey = baseKey; + const pathKey = `${baseKey}_path`; + + const sourceHasValue = Object.hasOwn(sectionSource, valueKey); + const sourceHasPath = Object.hasOwn(sectionSource, pathKey); + const defaultHasValue = Object.hasOwn(sectionDefault, valueKey); + const defaultHasPath = Object.hasOwn(sectionDefault, pathKey); + + if (sourceHasPath && !sourceHasValue && defaultHasValue) { + delete sectionOutput[valueKey]; + } else if (sourceHasValue && !sourceHasPath && defaultHasPath) { + delete sectionOutput[pathKey]; + } + } + } + } + } + return output; + } + + function isObject(item) { + return item && typeof item === 'object' && !Array.isArray(item); + } + + if (typeof customConfig === 'string') { + return writeTempFile('config.yaml', customConfig); + } + const mergedConfig = deepMerge(defaultConfig, customConfig); + return writeTempFile('config.yaml', yaml.dump(mergedConfig)); +} + +// Basic YAML object to string converter for the helper (no longer primary method) +function yamlToString(obj, indent = '') { + return Object.entries(obj) + .map(([key, value]) => { + if (typeof value === 'object' && value !== null) { + return `${indent}${key}:\n${yamlToString(value, `${indent} `)}`; + } + return `${indent}${key}: ${JSON.stringify(value)}`; + }) + .join('\n'); +} + +// Store original process.env to restore after tests that modify it +const originalEnv = { ...process.env }; + +describe('Configuration Loading', () => { + let loadConfig; + let ConfigError; + + beforeAll(async () => { + const module = await import('../app/server/config/loader.ts'); + loadConfig = module.loadConfig; + ConfigError = module.ConfigError; + }); + + beforeEach(() => { + // biome-ignore lint/performance/noDelete: Necessary for test environment cleanup + delete process.env.HEADPLANE_OIDC__CLIENT_SECRET; + // biome-ignore lint/performance/noDelete: Necessary for test environment cleanup + delete process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH; + // biome-ignore lint/performance/noDelete: Necessary for test environment cleanup + delete process.env.HEADPLANE_SERVER__COOKIE_SECRET; + // biome-ignore lint/performance/noDelete: Necessary for test environment cleanup + delete process.env.HEADPLANE_SERVER__COOKIE_SECRET_PATH; + // biome-ignore lint/performance/noDelete: Necessary for test environment cleanup + delete process.env.HEADPLANE_HEADSCALE__API_KEY; + // biome-ignore lint/performance/noDelete: Necessary for test environment cleanup + delete process.env.HEADPLANE_HEADSCALE__API_KEY_PATH; + // biome-ignore lint/performance/noDelete: Necessary for test environment cleanup + delete process.env.TEST_SECRET_DIR; + + // Create a dummy agent_cache.json file as the loader tries to read it + // when server.agent.cache_path is defined and loadEnv might be true. + const dummyCachePath = '/tmp/agent_cache.json'; + if (!fs.existsSync(dummyCachePath)) { + // Ensure directory exists if it's deeper, though /tmp should be fine + const dirname = path.dirname(dummyCachePath); + if (!fs.existsSync(dirname)) { + fs.mkdirSync(dirname, { recursive: true }); + } + fs.writeFileSync(dummyCachePath, JSON.stringify({})); + } + }); + + afterAll(() => { + Object.assign(process.env, originalEnv); + }); + + describe('OIDC Configuration', () => { + const minimalOidcRequiredFields = { + token_endpoint_auth_method: 'client_secret_basic', + disable_api_key_login: false, + headscale_api_key: 'dummyHeadscaleApiKeyForOidcBlock', + // client_secret or client_secret_path is provided by each test as needed + }; + + it('should load client_secret directly from YAML', async () => { + const tempConfigPath = writeTempYamlConfig({ + oidc: { + issuer: 'https://example.com/oidc', + client_id: 'test-client', + client_secret: 'yaml-direct-oidc-secret', + ...minimalOidcRequiredFields, + }, + }); + const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); + expect(config.oidc.client_secret).toBe('yaml-direct-oidc-secret'); + expect(config.oidc.client_secret_path).toBeUndefined(); + }); + + it('should load client_secret from file specified in client_secret_path', async () => { + const secretValue = 'yaml-file-oidc-secret'; + const secretFilePath = writeTempFile('oidc_secret.txt', secretValue); + const tempConfigPath = writeTempYamlConfig({ + oidc: { + issuer: 'https://example.com/oidc', + client_id: 'test-client', + client_secret_path: secretFilePath, + ...minimalOidcRequiredFields, + }, + }); + const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); + expect(config.oidc.client_secret).toBe(secretValue); + }); + + it('should override YAML client_secret with environment variable', async () => { + const envSecret = 'env-direct-oidc-secret'; + process.env.HEADPLANE_OIDC__CLIENT_SECRET = envSecret; + const tempConfigPath = writeTempYamlConfig({ + oidc: { + issuer: 'https://example.com/oidc', + client_id: 'test-client', + client_secret: 'yaml-to-be-overridden', // This will be overridden + ...minimalOidcRequiredFields, + }, + }); + const config = await loadConfig({ loadEnv: true, path: tempConfigPath }); + expect(config.oidc.client_secret).toBe(envSecret); + }); + + it('should override YAML client_secret_path with environment variable', async () => { + const secretValue = 'env-file-oidc-secret'; + const secretFilePath = writeTempFile('env_oidc_secret.txt', secretValue); + process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH = secretFilePath; + const tempConfigPath = writeTempYamlConfig({ + oidc: { + issuer: 'https://example.com/oidc', + client_id: 'test-client', + // Provide a placeholder client_secret to satisfy schema if issuer/client_id are present, + // as client_secret_path from YAML will be ignored due to env var taking precedence. + client_secret: 'placeholder-secret-for-schema-check', + ...minimalOidcRequiredFields, + }, + }); + const config = await loadConfig({ loadEnv: true, path: tempConfigPath }); + expect(config.oidc.client_secret).toBe(secretValue); + }); + + it('should handle environment variable interpolation in client_secret_path', async () => { + const secretValue = 'interpolated-env-file-oidc-secret'; + const secretDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'headplane-secret-dir-'), + ); + const secretFilePath = path.join(secretDir, 'interpolated_oidc.txt'); + fs.writeFileSync(secretFilePath, secretValue); + process.env.TEST_SECRET_DIR = secretDir; + process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH = + '${TEST_SECRET_DIR}/interpolated_oidc.txt'; + + const tempConfigPath = writeTempYamlConfig({ + oidc: { + issuer: 'https://example.com/oidc', + client_id: 'test-client', + client_secret: 'placeholder-for-schema-if-path-from-env', // Placeholder for schema validation + ...minimalOidcRequiredFields, + }, + }); + const config = await loadConfig({ loadEnv: true, path: tempConfigPath }); + expect(config.oidc.client_secret).toBe(secretValue); + }); + + it('should reject when both client_secret and client_secret_path are in YAML', async () => { + const secretFilePath = writeTempFile('conflict_oidc.txt', 'irrelevant'); + const tempConfigPath = writeTempYamlConfig({ + oidc: { + issuer: 'https://example.com/oidc', + client_id: 'test-client', + client_secret: 'yaml-direct-secret', + client_secret_path: secretFilePath, + ...minimalOidcRequiredFields, + }, + }); + let thrownError = null; + try { + await loadConfig({ loadEnv: false, path: tempConfigPath }); + } catch (e) { + thrownError = e; + } + expect(thrownError).not.toBeNull(); + if (thrownError) { + expect(thrownError.name).toBe('ConfigError'); + expect(thrownError.message).toMatch( + /Only one of "client_secret" or "client_secret_path" may be set/, + ); + } + }); + + it('should reject when client_secret_path points to non-existent file', async () => { + const tempConfigPath = writeTempYamlConfig({ + oidc: { + issuer: 'https://example.com/oidc', + client_id: 'test-client', + client_secret_path: '/path/to/non_existent_oidc_secret.txt', + ...minimalOidcRequiredFields, + }, + }); + await expect( + loadConfig({ loadEnv: false, path: tempConfigPath }), + ).rejects.toThrow(ConfigError); + await expect( + loadConfig({ loadEnv: false, path: tempConfigPath }), + ).rejects.toThrow(/File read error|ENOENT/); + }); + + it('should reject when client_secret_path has unresolvable env var interpolation', async () => { + process.env.HEADPLANE_OIDC__CLIENT_SECRET_PATH = + '${MISSING_TEST_SECRET_DIR}/secret.txt'; + const tempConfigPath = writeTempYamlConfig({ + oidc: { + issuer: 'https://example.com/oidc', + client_id: 'test-client', + client_secret: 'placeholder-for-schema-if-path-from-env', // Placeholder for schema validation + ...minimalOidcRequiredFields, + }, + }); + await expect( + loadConfig({ loadEnv: true, path: tempConfigPath }), + ).rejects.toThrow(ConfigError); + await expect( + loadConfig({ loadEnv: true, path: tempConfigPath }), + ).rejects.toThrow( + 'Environment variable "MISSING_TEST_SECRET_DIR" not found', + ); + }); + + it('should load from client_secret_path when client_secret is null in YAML', async () => { + const secretValue = 'oidc-secret-from-file-when-null'; + const secretFilePath = writeTempFile( + 'oidc_secret_via_path_null.txt', + secretValue, + ); + const tempConfigPath = writeTempYamlConfig({ + oidc: { + issuer: 'https://example.com/oidc', + client_id: 'test-client-null-case', + client_secret: null, // Explicitly null + client_secret_path: secretFilePath, + ...minimalOidcRequiredFields, // This will provide other required OIDC fields + }, + }); + const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); + expect(config.oidc.client_secret).toBe(secretValue); + // The loader should delete the path key after processing + expect(config.oidc.client_secret_path).toBeUndefined(); + }); + }); + + describe('Server Configuration', () => { + it('should load cookie_secret directly from YAML', async () => { + const validCookieSecret = 'abcdefghijklmnopqrstuvwxyz123456'; // 32 chars + const tempConfigPath = writeTempYamlConfig({ + server: { + // host and port will come from defaultConfig + cookie_secret: validCookieSecret, + // cookie_secure and agent will come from defaultConfig + }, + }); + const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); + expect(config.server.cookie_secret).toBe(validCookieSecret); + }); + + it('should load cookie_secret from file', async () => { + const secretValue = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; // Exactly 32 'a's + const secretFilePath = writeTempFile('cookie_secret.txt', secretValue); + const tempConfigPath = writeTempYamlConfig({ + server: { + cookie_secret_path: secretFilePath, + }, + }); + const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); + expect(config.server.cookie_secret).toBe(secretValue); + }); + + it('should reject when both cookie_secret and cookie_secret_path are in YAML', async () => { + const secretFilePath = writeTempFile( + 'conflict_cookie.txt', + 'irrelevant'.padEnd(32, 'X'), + ); + const tempConfigPath = writeTempYamlConfig({ + server: { + cookie_secret: '12345678901234567890123456789012', + cookie_secret_path: secretFilePath, + }, + }); + let thrownError = null; + try { + await loadConfig({ loadEnv: false, path: tempConfigPath }); + } catch (e) { + thrownError = e; + } + expect(thrownError).not.toBeNull(); + if (thrownError) { + expect(thrownError.name).toBe('ConfigError'); + expect(thrownError.message).toMatch( + /Only one of "cookie_secret" or "cookie_secret_path" may be set/, + ); + } + }); + + it('should reject when neither cookie_secret nor cookie_secret_path is provided', async () => { + const yamlWithoutServerCookieSecret = ` + debug: false + server: + host: "127.0.0.1" + port: 8080 + # cookie_secret and cookie_secret_path are intentionally omitted + cookie_secure: false + agent: + authkey: "testagentauthkey" + ttl: 180000 + cache_path: "/tmp/agent_cache.json" + headscale: + url: "http://localhost:8081" + config_strict: false + `; + const tempConfigPath = writeTempYamlConfig(yamlWithoutServerCookieSecret); + + await expect( + loadConfig({ loadEnv: false, path: tempConfigPath }), + ).rejects.toThrow(ConfigError); + await expect( + loadConfig({ loadEnv: false, path: tempConfigPath }), + ).rejects.toThrow( + 'Either "cookie_secret" or "cookie_secret_path" must be provided for cookie_secret', + ); + }); + + it('should allow server.agent.authkey to be null if no path is provided', async () => { + const tempConfigPath = writeTempYamlConfig({ + server: { + agent: { + authkey: null, // Explicitly null, no authkey_path + // Other agent fields will come from defaultConfig via deepMerge + }, + }, + // Other top-level required fields (headscale, debug) will come from defaultConfig + }); + const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); + expect(config.server.agent.authkey).toBeNull(); + }); + + it('should keep path string for server.agent.cache_path and interpolate env vars', async () => { + process.env.MY_AGENT_CACHE_SUBDIR = 'test-subdir'; + const cachePathValue = + '/tmp/my-agent-cache-${MY_AGENT_CACHE_SUBDIR}/cache.json'; + const expectedInterpolatedPath = + '/tmp/my-agent-cache-test-subdir/cache.json'; + + const tempConfigPath = writeTempYamlConfig({ + server: { + agent: { + cache_path: cachePathValue, + }, + }, + }); + + // Ensure the path does NOT exist, so if loader tries to read it, it would fail + // For safety, only attempt to delete if it's in /tmp/ + if ( + expectedInterpolatedPath.startsWith('/tmp/') && + fs.existsSync(expectedInterpolatedPath) + ) { + fs.unlinkSync(expectedInterpolatedPath); + } + if ( + expectedInterpolatedPath.startsWith('/tmp/') && + fs.existsSync(path.dirname(expectedInterpolatedPath)) + ) { + // fs.rmdirSync(path.dirname(expectedInterpolatedPath), { recursive: true }); // Be careful with rmdir + } + + const config = await loadConfig({ loadEnv: true, path: tempConfigPath }); + + expect(config.server.agent.cache_path).toBe(expectedInterpolatedPath); + }); + + it('should load server.agent.authkey from authkey_path when authkey is null in YAML', async () => { + const secretValue = 'agent-authkey-from-file'; + const secretFilePath = writeTempFile('agent_authkey.txt', secretValue); + const tempConfigPath = writeTempYamlConfig({ + server: { + agent: { + authkey: null, // Explicitly null + authkey_path: secretFilePath, + // ttl and cache_path will come from defaultConfig + }, + }, + }); + const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); + expect(config.server.agent.authkey).toBe(secretValue); + expect(config.server.agent.authkey_path).toBeUndefined(); + }); + + it('should reject if both server.agent.authkey and authkey_path are provided', async () => { + const secretFilePath = writeTempFile( + 'agent_authkey_conflict.txt', + 'some-content', + ); + const tempConfigPath = writeTempYamlConfig({ + server: { + agent: { + authkey: 'direct-agent-authkey', + authkey_path: secretFilePath, + }, + }, + }); + let thrownError = null; + try { + await loadConfig({ loadEnv: false, path: tempConfigPath }); + } catch (e) { + thrownError = e; + } + expect(thrownError).not.toBeNull(); + if (thrownError) { + expect(thrownError.name).toBe('ConfigError'); + expect(thrownError.message).toMatch( + /Only one of agent "authkey" or "authkey_path" may be set/, + ); + } + }); + }); + + describe('Headscale Configuration', () => { + it('should load api_key directly from YAML', async () => { + const tempConfigPath = writeTempYamlConfig({ + headscale: { + // url and config_strict will come from defaultConfig + api_key: 'hs-yaml-direct-api-key', + }, + }); + const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); + expect(config.headscale.api_key).toBe('hs-yaml-direct-api-key'); + }); + + it('should load api_key from file', async () => { + const secretValue = 'hs-file-api-key'; + const secretFilePath = writeTempFile('hs_api_key.txt', secretValue); + const tempConfigPath = writeTempYamlConfig({ + headscale: { + api_key_path: secretFilePath, + }, + }); + const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); + expect(config.headscale.api_key).toBe(secretValue); + }); + + it('should allow neither api_key nor api_key_path (optional)', async () => { + const tempConfigPath = writeTempYamlConfig({ + headscale: { + // api_key and api_key_path are absent, should be fine + }, + }); + const config = await loadConfig({ loadEnv: false, path: tempConfigPath }); + expect(config.headscale.api_key).toBeUndefined(); + expect(config.headscale.api_key_path).toBeUndefined(); + }); + + it('should reject when both api_key and api_key_path are in YAML', async () => { + const secretFilePath = writeTempFile('conflict_hs_api.txt', 'irrelevant'); + const tempConfigPath = writeTempYamlConfig({ + headscale: { + api_key: 'hs-direct-key', + api_key_path: secretFilePath, + }, + }); + let thrownError = null; + try { + await loadConfig({ loadEnv: false, path: tempConfigPath }); + } catch (e) { + thrownError = e; + } + expect(thrownError).not.toBeNull(); + if (thrownError) { + expect(thrownError.name).toBe('ConfigError'); + expect(thrownError.message).toMatch( + /Only one of "api_key" or "api_key_path" may be set/, + ); + } + }); + + it('should keep path string for headscale.config_path and interpolate env vars', async () => { + process.env.MY_HS_CONFIG_SUBDIR = 'hs-test-subdir'; + const configPathValue = + '/etc/headscale-${MY_HS_CONFIG_SUBDIR}/config.yaml'; + const expectedInterpolatedPath = + '/etc/headscale-hs-test-subdir/config.yaml'; + + const tempConfigPath = writeTempYamlConfig({ + headscale: { + config_path: configPathValue, + }, + }); + + const config = await loadConfig({ loadEnv: true, path: tempConfigPath }); + + expect(config.headscale.config_path).toBe(expectedInterpolatedPath); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..44ddf54 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/*.test.js'], + }, + resolve: { + alias: { + '~': resolve(__dirname, './app'), + '~server': resolve(__dirname, './server'), + }, + }, +});