feat: do insane type validation for the config

This commit is contained in:
Aarnav Tale 2025-08-21 11:16:27 -04:00
parent 82f6294499
commit 356abab90a
No known key found for this signature in database
2 changed files with 52 additions and 33 deletions

View File

@ -108,7 +108,10 @@ export default function Page() {
</span> </span>
<div className="flex items-center gap-x-2.5 mt-1"> <div className="flex items-center gap-x-2.5 mt-1">
<UserCircle /> <UserCircle />
{node.user.name || node.user.displayName || node.user.email || node.user.id} {node.user.name ||
node.user.displayName ||
node.user.email ||
node.user.id}
</div> </div>
</div> </div>
<div className="p-2 pl-4"> <div className="p-2 pl-4">
@ -253,7 +256,15 @@ export default function Page() {
variant="flat" variant="flat"
> >
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Attribute name="Creator" value={node.user.name || node.user.displayName || node.user.email || node.user.id} /> <Attribute
name="Creator"
value={
node.user.name ||
node.user.displayName ||
node.user.email ||
node.user.id
}
/>
<Attribute name="Machine name" value={node.givenName} /> <Attribute name="Machine name" value={node.givenName} />
<Attribute <Attribute
name="OS hostname" name="OS hostname"

View File

@ -1,4 +1,4 @@
import { constants, access, readFile } from 'node:fs/promises'; import { access, constants, readFile } from 'node:fs/promises';
import { env } from 'node:process'; import { env } from 'node:process';
import { type } from 'arktype'; import { type } from 'arktype';
import { configDotenv } from 'dotenv'; import { configDotenv } from 'dotenv';
@ -23,8 +23,8 @@ export class ConfigError extends Error {
* Interpolate environment variables in a string * Interpolate environment variables in a string
* Replaces ${VAR_NAME} patterns with the actual environment variable values * Replaces ${VAR_NAME} patterns with the actual environment variable values
*/ */
function interpolateEnvVars(str: string): string { export function interpolateEnvVars(str: string): string {
return str.replace(/\$\{([^}]+)\}/g, (match, varName) => { return str.replace(/\$\{([^}]+)\}/g, (_, varName) => {
const value = env[varName]; const value = env[varName];
if (value === undefined) { if (value === undefined) {
throw new ConfigError(`Environment variable "${varName}" not found`); throw new ConfigError(`Environment variable "${varName}" not found`);
@ -52,9 +52,9 @@ export async function loadConfig({ loadEnv, path }: EnvOverrides) {
if (!loadEnv) { if (!loadEnv) {
log.debug('config', 'Environment variable overrides are disabled'); log.debug('config', 'Environment variable overrides are disabled');
log.debug('config', 'This also disables the loading of a .env file'); log.debug('config', 'This also disables the loading of a .env file');
config = await loadSecretsFromFiles(config); const moddedConfig = await loadSecretsFromFiles(config);
log.debug('config', 'Loaded file-based secrets'); log.debug('config', 'Loaded file-based secrets');
return config; return moddedConfig;
} }
log.info('config', 'Loading a .env file (if available)'); log.info('config', 'Loading a .env file (if available)');
@ -67,10 +67,10 @@ export async function loadConfig({ loadEnv, path }: EnvOverrides) {
); );
} }
config = await loadSecretsFromFiles(config); const moddedConfig = await loadSecretsFromFiles(config);
log.debug('config', 'Loaded file-based secrets'); log.debug('config', 'Loaded file-based secrets');
return config; return moddedConfig;
} }
/** /**
@ -78,13 +78,40 @@ export async function loadConfig({ loadEnv, path }: EnvOverrides) {
* reads that file and assigns its contents to the corresponding key * reads that file and assigns its contents to the corresponding key
* without the suffix, then removes the "_path" property. * without the suffix, then removes the "_path" property.
*/ */
const SECRET_PATH_KEYS = new Set([ const SECRET_PATH_KEYS = [
'pre_authkey_path', 'pre_authkey_path',
'client_secret_path', 'client_secret_path',
'headscale_api_key_path', 'headscale_api_key_path',
'cookie_secret_path', 'cookie_secret_path',
]); ] as const;
async function loadSecretsFromFiles<T extends object>(obj: T): Promise<T> {
// For fast set hashing lookups, but we still need the array for typings
const SECRET_PATH_KEY_SET = new Set<string>(SECRET_PATH_KEYS);
type SecretPathKey = (typeof SECRET_PATH_KEYS)[number];
type StripPath<S extends string> = S extends `${infer T}_path` ? T : never;
type KeysToPromote<T> = Extract<keyof T & string, SecretPathKey>;
type MappedKeys<T> = StripPath<KeysToPromote<T>>;
type NonNullablized<T> = Omit<T, KeysToPromote<T> | MappedKeys<T>> & {
[K in MappedKeys<T>]-?: string;
};
type NestedNonNullablized<T> = T extends readonly (infer U)[]
? readonly NestedNonNullablized<U>[]
: T extends (infer U)[]
? NestedNonNullablized<U>[]
: T extends object
? {
[K in keyof NonNullablized<T>]: NestedNonNullablized<
NonNullablized<T>[K]
>;
}
: T;
async function loadSecretsFromFiles<T extends object>(
obj: T,
): Promise<NestedNonNullablized<T>> {
// Work with a Record so we can mutate/delete properties // Work with a Record so we can mutate/delete properties
const record = obj as Record<string, unknown>; const record = obj as Record<string, unknown>;
@ -97,7 +124,7 @@ async function loadSecretsFromFiles<T extends object>(obj: T): Promise<T> {
continue; continue;
} }
if (SECRET_PATH_KEYS.has(key) && typeof val === 'string') { if (SECRET_PATH_KEY_SET.has(key) && typeof val === 'string') {
try { try {
const path = interpolateEnvVars(val); const path = interpolateEnvVars(val);
const content = await readFile(path, 'utf8'); const content = await readFile(path, 'utf8');
@ -114,26 +141,7 @@ async function loadSecretsFromFiles<T extends object>(obj: T): Promise<T> {
} }
// Cast back to the original T so callers keep their precise type // Cast back to the original T so callers keep their precise type
return record as T; return record as NestedNonNullablized<T>;
}
export async function hp_loadConfig() {
// // OIDC Related Checks
// if (config.oidc) {
// if (!config.oidc.client_secret && !config.oidc.client_secret_path) {
// log.error('CFGX', 'OIDC configuration is missing a secret, disabling');
// log.error(
// 'CFGX',
// 'Please specify either `oidc.client_secret` or `oidc.client_secret_path`',
// );
// }
// if (config.oidc?.strict_validation) {
// const result = await testOidc(config.oidc);
// if (!result) {
// log.error('CFGX', 'OIDC configuration failed validation, disabling');
// }
// }
// }
} }
async function validateConfigPath(path: string) { async function validateConfigPath(path: string) {