From 8761cb9aed9b417f09befe93ca54a142d9611a67 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 6 Oct 2025 14:51:10 -0700 Subject: [PATCH 1/7] Introduce envconfig package --- package-lock.json | 53 +- package.json | 2 + packages/envconfig/README.md | 5 + packages/envconfig/package.json | 40 + packages/envconfig/src/envconfig-toml.ts | 333 +++++++ packages/envconfig/src/envconfig.ts | 77 ++ packages/envconfig/src/index.ts | 20 + packages/envconfig/src/types.ts | 137 +++ packages/envconfig/src/utils.ts | 155 ++++ packages/envconfig/tsconfig.json | 9 + packages/test/package.json | 1 + packages/test/src/test-envconfig.ts | 1042 ++++++++++++++++++++++ packages/test/tsconfig.json | 1 + tsconfig.prune.json | 1 + 14 files changed, 1874 insertions(+), 2 deletions(-) create mode 100644 packages/envconfig/README.md create mode 100644 packages/envconfig/package.json create mode 100644 packages/envconfig/src/envconfig-toml.ts create mode 100644 packages/envconfig/src/envconfig.ts create mode 100644 packages/envconfig/src/index.ts create mode 100644 packages/envconfig/src/types.ts create mode 100644 packages/envconfig/src/utils.ts create mode 100644 packages/envconfig/tsconfig.json create mode 100644 packages/test/src/test-envconfig.ts diff --git a/package-lock.json b/package-lock.json index 921b96cd4..df6d10099 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "packages/common", "packages/core-bridge", "packages/create-project", + "packages/envconfig", "packages/interceptors-opentelemetry", "packages/nexus", "packages/nyc-test-coverage", @@ -27,6 +28,7 @@ "@temporalio/cloud": "file:packages/cloud", "@temporalio/common": "file:packages/common", "@temporalio/create": "file:packages/create-project", + "@temporalio/envconfig": "file:packages/envconfig", "@temporalio/interceptors-opentelemetry": "file:packages/interceptors-opentelemetry", "@temporalio/nexus": "file:packages/nexus", "@temporalio/nyc-test-coverage": "file:packages/nyc-test-coverage", @@ -2785,6 +2787,10 @@ "resolved": "packages/create-project", "link": true }, + "node_modules/@temporalio/envconfig": { + "resolved": "packages/envconfig", + "link": true + }, "node_modules/@temporalio/interceptors-opentelemetry": { "resolved": "packages/interceptors-opentelemetry", "link": true @@ -15877,6 +15883,18 @@ "npm": ">= 3.0.0" } }, + "node_modules/smol-toml": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz", + "integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -18397,6 +18415,19 @@ "typedoc-plugin-markdown": "^3.17.1" } }, + "packages/envconfig": { + "name": "@temporalio/envconfig", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@temporalio/client": "file:../client", + "@temporalio/common": "file:../common", + "smol-toml": "1.4.2" + }, + "engines": { + "node": ">= 18.0.0" + } + }, "packages/interceptors-opentelemetry": { "name": "@temporalio/interceptors-opentelemetry", "version": "1.13.0", @@ -18433,6 +18464,7 @@ "@temporalio/client": "file:../client", "@temporalio/common": "file:../common", "@temporalio/interceptors-opentelemetry": "file:../interceptors-opentelemetry", + "@temporalio/nexus": "file:../nexus", "@temporalio/proto": "file:../proto", "@temporalio/testing": "file:../testing", "@temporalio/worker": "file:../worker", @@ -18518,6 +18550,7 @@ "@temporalio/cloud": "file:../cloud", "@temporalio/common": "file:../common", "@temporalio/core-bridge": "file:../core-bridge", + "@temporalio/envconfig": "file:../envconfig", "@temporalio/interceptors-opentelemetry": "file:../interceptors-opentelemetry", "@temporalio/nexus": "file:../nexus", "@temporalio/nyc-test-coverage": "file:../nyc-test-coverage", @@ -20595,6 +20628,14 @@ "validate-npm-package-name": "^5.0.0" } }, + "@temporalio/envconfig": { + "version": "file:packages/envconfig", + "requires": { + "@temporalio/client": "file:../client", + "@temporalio/common": "file:../common", + "smol-toml": "1.4.2" + } + }, "@temporalio/interceptors-opentelemetry": { "version": "file:packages/interceptors-opentelemetry", "requires": { @@ -20661,6 +20702,7 @@ "@temporalio/cloud": "file:../cloud", "@temporalio/common": "file:../common", "@temporalio/core-bridge": "file:../core-bridge", + "@temporalio/envconfig": "file:../envconfig", "@temporalio/interceptors-opentelemetry": "file:../interceptors-opentelemetry", "@temporalio/nexus": "file:../nexus", "@temporalio/nyc-test-coverage": "file:../nyc-test-coverage", @@ -27574,8 +27616,9 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "nexus-rpc": { - "version": "git+ssh://git@github.com/nexus-rpc/sdk-typescript.git#f594a7fd9e33bd14e5ce1ed04c5225fc708e7866", - "from": "nexus-rpc@^0.0.1" + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/nexus-rpc/-/nexus-rpc-0.0.1.tgz", + "integrity": "sha512-hAWn8Hh2eewpB5McXR5EW81R3pR/ziuGhKCF3wFyUVCklanPqrIgMNr7jKCbzXeNVad0nUDfWpFRqh2u+zxQtw==" }, "nice-try": { "version": "1.0.5", @@ -29961,6 +30004,11 @@ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "dev": true }, + "smol-toml": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz", + "integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==" + }, "socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -30418,6 +30466,7 @@ "@temporalio/client": "file:../client", "@temporalio/common": "file:../common", "@temporalio/interceptors-opentelemetry": "file:../interceptors-opentelemetry", + "@temporalio/nexus": "file:../nexus", "@temporalio/proto": "file:../proto", "@temporalio/testing": "file:../testing", "@temporalio/worker": "file:../worker", diff --git a/package.json b/package.json index 93d406579..c7a9c5ff7 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@temporalio/cloud": "file:packages/cloud", "@temporalio/common": "file:packages/common", "@temporalio/create": "file:packages/create-project", + "@temporalio/envconfig": "file:packages/envconfig", "@temporalio/interceptors-opentelemetry": "file:packages/interceptors-opentelemetry", "@temporalio/nexus": "file:packages/nexus", "@temporalio/nyc-test-coverage": "file:packages/nyc-test-coverage", @@ -87,6 +88,7 @@ "packages/common", "packages/core-bridge", "packages/create-project", + "packages/envconfig", "packages/interceptors-opentelemetry", "packages/nexus", "packages/nyc-test-coverage", diff --git a/packages/envconfig/README.md b/packages/envconfig/README.md new file mode 100644 index 000000000..0c0cd9400 --- /dev/null +++ b/packages/envconfig/README.md @@ -0,0 +1,5 @@ +# `@temporalio/envconfig` + +[![NPM](https://img.shields.io/npm/v/@temporalio/envconfig?style=for-the-badge)](https://www.npmjs.com/package/@temporalio/envconfig) + +Part of [Temporal](https://temporal.io)'s [TypeScript SDK](https://docs.temporal.io/typescript/introduction/). diff --git a/packages/envconfig/package.json b/packages/envconfig/package.json new file mode 100644 index 000000000..d39b6c585 --- /dev/null +++ b/packages/envconfig/package.json @@ -0,0 +1,40 @@ +{ + "name": "@temporalio/envconfig", + "version": "1.0.0", + "description": "Temporal.io SDK Environment Configuration sub-package", + "main": "lib/index.js", + "types": "./lib/index.d.ts", + "scripts": {}, + "keywords": [ + "temporal", + "environment", + "configuration", + "client" + ], + "author": "Temporal Technologies Inc. ", + "license": "MIT", + "dependencies": { + "@temporalio/common": "file:../common", + "@temporalio/worker": "file:../worker", + "smol-toml": "1.4.2" + }, + "engines": { + "node": ">= 18.0.0" + }, + "bugs": { + "url": "https://github.com/temporalio/sdk-typescript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/temporalio/sdk-typescript.git", + "directory": "packages/envconfig" + }, + "homepage": "https://github.com/temporalio/sdk-typescript/tree/main/packages/envconfig", + "publishConfig": { + "access": "public" + }, + "files": [ + "src", + "lib" + ] +} diff --git a/packages/envconfig/src/envconfig-toml.ts b/packages/envconfig/src/envconfig-toml.ts new file mode 100644 index 000000000..bdd36aa50 --- /dev/null +++ b/packages/envconfig/src/envconfig-toml.ts @@ -0,0 +1,333 @@ +import * as path from 'path'; +import * as os from 'os'; +import { parse, stringify, TomlTable } from 'smol-toml'; +import { + ConfigDataSource, + LoadClientConfigOptions, + LoadClientProfileOptions, + tomlClientConfig, + tomlClientConfigCodec, + tomlClientConfigProfile, + tomlClientConfigTLS, +} from './types'; +import { normalizeGrpcMetaKey, readPathSync } from './utils'; + +function sourceToStringData(source?: ConfigDataSource): string | undefined { + if (source === undefined) { + return undefined; + } + if ('path' in source) { + return readPathSync(source.path)?.toString(); + } + + if (Buffer.isBuffer(source.data)) { + return source.data.toString(); + } + return source.data; +} + +type EnvProvider = { kind: 'Map'; map: Record } | { kind: 'System' }; + +function getEnvVar(provider: EnvProvider, key: string): string | undefined { + if (provider.kind === 'Map') { + return provider.map[key]; + } + return process.env[key]; +} + +export function tomlLoadClientConfig(options: LoadClientConfigOptions): tomlClientConfig { + const envProvider: EnvProvider = options.overrideEnvVars + ? { kind: 'Map', map: options.overrideEnvVars } + : { kind: 'System' }; + + let configData = undefined; + try { + configData = sourceToStringData(options.configSource) ?? getFallbackConfigData(envProvider); + } catch (error) { + const isFileNotFound = error instanceof Error && 'code' in error && error.code === 'ENOENT'; + if (!isFileNotFound) { + throw error; + } + // File not found is ok + } + if (configData !== undefined) { + return loadFromTomlData(configData, options.configFileStrict ?? false); + } + return { profile: {} }; // default ClientConfig +} + +export function loadFromTomlData(tomlData: string, isStrict: boolean): tomlClientConfig { + const parsed = parse(tomlData); + if (isStrict) { + strictValidateTomlStructure(parsed); + } + + return parsed as unknown as tomlClientConfig; +} + +export function configToTomlData(config: tomlClientConfig): Buffer { + return Buffer.from(stringify(config), 'utf-8'); +} + +function strictValidateTomlStructure(parsed: TomlTable): void { + const allowedTopLevel = new Set(['profile']); + const allowedProfile = new Set(['address', 'namespace', 'api_key', 'tls', 'codec', 'grpc_meta']); + const allowedTLS = new Set([ + 'disabled', + 'client_cert_path', + 'client_key_path', + 'client_cert_data', + 'client_key_data', + 'server_ca_cert_path', + 'server_ca_cert_data', + 'server_name', + ]); + const allowedCodec = new Set(['endpoint', 'auth']); + + // Check top-level keys + const unknownTopLevel = Object.keys(parsed).filter((k) => !allowedTopLevel.has(k)); + if (unknownTopLevel.length > 0) { + throw new Error(`Validation error: key(s) unrecognized: ${unknownTopLevel.join(', ')}`); + } + + const profiles = parsed.profile; + if (profiles === undefined) return; + + // Ensure it's a TomlTable (not a primitive or array) + if (typeof profiles !== 'object' || Array.isArray(profiles)) { + throw new Error('Validation error: profile must be a table'); + } + + for (const [profileName, profileData] of Object.entries(profiles)) { + // Ensure profile is a table + if (typeof profileData !== 'object' || Array.isArray(profileData)) { + throw new Error(`Validation error: profile.${profileName} must be a table`); + } + + const unknownProfile = Object.keys(profileData).filter((k) => !allowedProfile.has(k)); + if (unknownProfile.length > 0) { + throw new Error(`Validation error: key(s) unrecognized in profile.${profileName}: ${unknownProfile.join(', ')}`); + } + + // Validate TLS + const tls = profileData.tls; + if (tls !== undefined) { + if (typeof tls !== 'object' || Array.isArray(tls)) { + throw new Error(`Validation error: profile.${profileName}.tls must be a table`); + } + const unknownTLS = Object.keys(tls).filter((k) => !allowedTLS.has(k)); + if (unknownTLS.length > 0) { + throw new Error( + `Validation error: key(s) unrecognized in profile.${profileName}.tls: ${unknownTLS.join(', ')}` + ); + } + } + + // Validate codec + const codec = profileData.codec; + if (codec !== undefined) { + if (typeof codec !== 'object' || Array.isArray(codec)) { + throw new Error(`Validation error: profile.${profileName}.codec must be a table`); + } + const unknownCodec = Object.keys(codec).filter((k) => !allowedCodec.has(k)); + if (unknownCodec.length > 0) { + throw new Error( + `Validation error: key(s) unrecognized in profile.${profileName}.codec: ${unknownCodec.join(', ')}` + ); + } + } + } +} + +function getFallbackConfigData(envProvider: EnvProvider): string | undefined { + // configSource was not set - fallback to TEMPORAL_CONFIG_FILE, then the default file path + let filePath = getEnvVar(envProvider, 'TEMPORAL_CONFIG_FILE'); + if (filePath === undefined) { + filePath = getDefaultConfigFilePath(); + } + return readPathSync(filePath)?.toString(); +} + +export function tomlLoadClientConfigProfile(options: LoadClientProfileOptions): tomlClientConfigProfile { + if (options.disableEnv && options.disableFile) { + throw new Error('Cannot disable both file and environment loading'); + } + + const envProvider: EnvProvider = options.overrideEnvVars + ? { kind: 'Map', map: options.overrideEnvVars } + : { kind: 'System' }; + + let profile: tomlClientConfigProfile = {}; + + if (!options.disableFile) { + const tomlClientConfig = tomlLoadClientConfig({ + configSource: options.configSource, + configFileStrict: options.configFileStrict, + overrideEnvVars: options.overrideEnvVars, + }); + let profileName = options.profile; + let profileSet = true; + // If profile name not provided, fallback to env variable. + if (profileName === undefined) { + profileName = getEnvVar(envProvider, 'TEMPORAL_PROFILE'); + } + // If env var also not provided, fallback to default profile name. + if (profileName === undefined) { + profileName = DEFAULT_CONFIG_FILE_PROFILE; + profileSet = false; + } + const tomlProfile = tomlClientConfig.profile[profileName]; + // If toml profile does not exist and an explicit profile was requested, error. + if (tomlProfile === undefined && profileSet) { + throw new Error(`Profile '${profileName}' not found in config data`); + } + // Use loaded profile if exists, otherwise fallback to default profile. + profile = tomlProfile ?? {}; + } + + if (!options.disableEnv) { + applyProfileEnvVars(profile, envProvider); + } + return profile; +} + +function applyProfileEnvVars(profile: tomlClientConfigProfile, envProvider: EnvProvider) { + profile.address = getEnvVar(envProvider, 'TEMPORAL_ADDRESS') ?? profile.address; + profile.namespace = getEnvVar(envProvider, 'TEMPORAL_NAMESPACE') ?? profile.namespace; + profile.api_key = getEnvVar(envProvider, 'TEMPORAL_API_KEY') ?? profile.api_key; + const tlsFromEnv = getTLSFromEnvVars(envProvider); + profile.tls = profile.tls || tlsFromEnv ? { ...profile.tls, ...tlsFromEnv } : undefined; + const codecFromEnv = getCodecFromEnvVars(envProvider); + profile.codec = profile.codec || codecFromEnv ? { ...profile.codec, ...codecFromEnv } : undefined; + applyGrpcMetaFromEnvVars(profile, envProvider); +} + +function getTLSFromEnvVars(envProvider: EnvProvider): tomlClientConfigTLS | undefined { + const tlsConfig: tomlClientConfigTLS = {}; + const tlsEnvVar = getEnvVar(envProvider, 'TEMPORAL_TLS'); + if (tlsEnvVar !== undefined) { + tlsConfig.disabled = envVarToBool(tlsEnvVar); + } + const clientCertPath = getEnvVar(envProvider, 'TEMPORAL_TLS_CLIENT_CERT_PATH'); + if (clientCertPath !== undefined) { + tlsConfig.client_cert_path = clientCertPath; + } + const clientCertData = getEnvVar(envProvider, 'TEMPORAL_TLS_CLIENT_CERT_DATA'); + if (clientCertData !== undefined) { + tlsConfig.client_cert_data = clientCertData; + } + const clientKeyPath = getEnvVar(envProvider, 'TEMPORAL_TLS_CLIENT_KEY_PATH'); + if (clientKeyPath !== undefined) { + tlsConfig.client_key_path = clientKeyPath; + } + const clientKeyData = getEnvVar(envProvider, 'TEMPORAL_TLS_CLIENT_KEY_DATA'); + if (clientKeyData !== undefined) { + tlsConfig.client_key_data = clientKeyData; + } + const serverCertPath = getEnvVar(envProvider, 'TEMPORAL_TLS_SERVER_CA_CERT_PATH'); + if (serverCertPath !== undefined) { + tlsConfig.server_ca_cert_path = serverCertPath; + } + const serverCertData = getEnvVar(envProvider, 'TEMPORAL_TLS_SERVER_CA_CERT_DATA'); + if (serverCertData !== undefined) { + tlsConfig.server_ca_cert_data = serverCertData; + } + const serverName = getEnvVar(envProvider, 'TEMPORAL_TLS_SERVER_NAME'); + if (serverName !== undefined) { + tlsConfig.server_name = serverName; + } + const disableHostVerification = getEnvVar(envProvider, 'TEMPORAL_TLS_DISABLE_HOST_VERIFICATION'); + if (disableHostVerification !== undefined) { + tlsConfig.disable_host_verification = envVarToBool(disableHostVerification); + } + // If no properties were added, return undefined + return Object.keys(tlsConfig).length > 0 ? tlsConfig : undefined; +} + +function getCodecFromEnvVars(envProvider: EnvProvider): tomlClientConfigCodec | undefined { + const codec: tomlClientConfigCodec = {}; + const endpoint = getEnvVar(envProvider, 'TEMPORAL_CODEC_ENDPOINT'); + if (endpoint !== undefined) { + codec.endpoint = endpoint; + } + const auth = getEnvVar(envProvider, 'TEMPORAL_CODEC_AUTH'); + if (auth !== undefined) { + codec.auth = auth; + } + // If no properties were added, return undefined + return Object.keys(codec).length > 0 ? codec : undefined; +} + +function applyGrpcMetaFromEnvVars(profile: tomlClientConfigProfile, envProvider: EnvProvider) { + const PREFIX = 'TEMPORAL_GRPC_META_'; + + // Get key-value pairs based on provider type + const envVars = envProvider.kind === 'Map' ? Object.entries(envProvider.map) : Object.entries(process.env); + + for (const [key, value] of envVars) { + if (key.startsWith(PREFIX)) { + const headerName = key.slice(PREFIX.length); + const normalizedKey = normalizeGrpcMetaKey(headerName); + if (profile.grpc_meta === undefined) { + profile.grpc_meta = {}; + } + + // Empty values remove the key, non-empty values set it + if (value === '' || value == null) { + delete profile.grpc_meta[normalizedKey]; + } else { + profile.grpc_meta[normalizedKey] = value; + } + } + } +} + +function envVarToBool(envVar: string): boolean { + return envVar === '1' || envVar === 'true'; +} + +const DEFAULT_CONFIG_FILE_PROFILE = 'default'; +const DEFAULT_CONFIG_FILE = 'config.toml'; + +function getDefaultConfigFilePath(): string { + const configDir = getUserConfigDir(); + const configPath = path.join(configDir, 'temporalio', DEFAULT_CONFIG_FILE); + return configPath; +} + +function getUserConfigDir(): string { + const platform = os.platform(); + + switch (platform) { + case 'win32': { + const dir = process.env.APPDATA; + if (!dir) { + throw new Error('%AppData% is not defined'); + } + return dir; + } + + case 'darwin': { + const dir = process.env.HOME; + if (!dir) { + throw new Error('$HOME is not defined'); + } + return path.join(dir, 'Library', 'Application Support'); + } + + default: { + // Unix/Linux + let dir = process.env.XDG_CONFIG_HOME; + if (!dir) { + const home = process.env.HOME; + if (!home) { + throw new Error('neither $XDG_CONFIG_HOME nor $HOME are defined'); + } + dir = path.join(home, '.config'); + } else if (!path.isAbsolute(dir)) { + throw new Error('path in $XDG_CONFIG_HOME is relative'); + } + return dir; + } + } +} diff --git a/packages/envconfig/src/envconfig.ts b/packages/envconfig/src/envconfig.ts new file mode 100644 index 000000000..3ad88f704 --- /dev/null +++ b/packages/envconfig/src/envconfig.ts @@ -0,0 +1,77 @@ +import type { TLSConfig } from '@temporalio/common/lib/internal-non-workflow'; +import { + configToTomlData, + loadFromTomlData, + tomlLoadClientConfig, + tomlLoadClientConfigProfile, +} from './envconfig-toml'; +import { + ClientConfig, + ClientConfigFromTomlOptions, + ClientConfigProfile, + ClientConfigTLS, + ClientConnectConfig, + LoadClientConfigOptions, + LoadClientProfileOptions, +} from './types'; +import { fromTomlConfig, fromTomlProfile, loadConfigData, toTomlConfig } from './utils'; + +export function loadClientConfig(options: LoadClientConfigOptions): ClientConfig { + return fromTomlConfig(tomlLoadClientConfig(options)); +} + +export function loadClientConfigFromToml(tomlData: Buffer, options: ClientConfigFromTomlOptions): ClientConfig { + return fromTomlConfig(loadFromTomlData(tomlData.toString(), options.strict)); +} + +export function clientConfigToToml(config: ClientConfig): Buffer { + return configToTomlData(toTomlConfig(config)); +} + +export function loadClientConfigProfile(options: LoadClientProfileOptions = {}): ClientConfigProfile { + return fromTomlProfile(tomlLoadClientConfigProfile(options)); +} + +export function loadClientConnectConfig(options: LoadClientProfileOptions = {}): ClientConnectConfig { + return toClientOptions(loadClientConfigProfile(options)); +} + +export function toClientOptions(profile: ClientConfigProfile): ClientConnectConfig { + // TLS is enabled if we have an explicit TLS config, or if an api key is provided. + const tls = toTLSConfig(profile.tls) ?? (profile.apiKey !== undefined ? true : undefined); + return { + namespace: profile.namespace, + connectionOptions: { + address: profile.address, + apiKey: profile.apiKey, + tls, + metadata: profile.grpcMeta, + }, + }; +} + +export function toTLSConfig(config?: ClientConfigTLS): TLSConfig | boolean | undefined { + if (config === undefined) { + return undefined; + } + if (config.disabled === true) { + return false; + } + + const serverRootCACert = loadConfigData(config.serverCACert); + const crtBuffer = loadConfigData(config.clientCert); + const keyBuffer = loadConfigData(config.clientKey); + + const tlsConfig: TLSConfig = { + serverNameOverride: config.serverName, + serverRootCACertificate: serverRootCACert, + clientCertPair: + crtBuffer && keyBuffer + ? { + crt: crtBuffer, + key: keyBuffer, + } + : undefined, + }; + return tlsConfig; +} diff --git a/packages/envconfig/src/index.ts b/packages/envconfig/src/index.ts new file mode 100644 index 000000000..eb47b0da0 --- /dev/null +++ b/packages/envconfig/src/index.ts @@ -0,0 +1,20 @@ +export { + loadClientConfig, + loadClientConfigProfile, + loadClientConnectConfig, + loadClientConfigFromToml, + clientConfigToToml, + toClientOptions, +} from './envconfig'; + +export { + ClientConfig, + ClientConfigProfile, + ClientConfigTLS, + LoadClientConfigOptions, + LoadClientProfileOptions, + ClientConfigFromTomlOptions, + ConfigDataSource, +} from './types'; + +export { fromTomlConfig, fromTomlProfile, toTomlConfig, toTomlProfile, loadConfigData } from './utils'; diff --git a/packages/envconfig/src/types.ts b/packages/envconfig/src/types.ts new file mode 100644 index 000000000..2668095c7 --- /dev/null +++ b/packages/envconfig/src/types.ts @@ -0,0 +1,137 @@ +import { NativeConnectionOptions } from '@temporalio/worker'; + +/** + * A data source for configuration, which can be a path to a file, + * the string contents of a file, or raw bytes. + */ +export type ConfigDataSource = { path: string } | { data: string | Buffer }; + +/** + * TLS configuration as specified as part of client configuration. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export interface ClientConfigTLS { + disabled?: boolean; + serverName?: string; + clientCert?: ConfigDataSource; + clientKey?: ConfigDataSource; + serverCACert?: ConfigDataSource; +} + +export interface ClientConnectConfig { + connectionOptions: NativeConnectionOptions; + namespace?: string; +} + +export interface ClientConfigProfileOptions { + address?: string; + namespace?: string; + apiKey?: string; + tls?: ClientConfigTLS; + grpcMeta?: Record; +} + +/** + * Options for loading a client configuration profile. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export interface LoadClientProfileOptions { + /** The profile to load from the config. Defaults to "default". */ + profile?: string; + /** + * If present, this is used as the configuration source instead of default + * file locations. This can be a path to the file or the string/byte + * contents of the file. + */ + configSource?: ConfigDataSource; + /** + * If true, file loading is disabled. This is only used when `configSource` + * is not present. + */ + disableFile?: boolean; + /** If true, environment variable loading and overriding is disabled. */ + disableEnv?: boolean; + /** If true, will error on unrecognized keys in the TOML file. */ + configFileStrict?: boolean; + /** + * A dictionary of environment variables to use for loading and overrides. + * If not provided, the current process's environment is used. + */ + overrideEnvVars?: Record; +} + +export interface ClientConfigProfile { + address?: string; + namespace?: string; + apiKey?: string; + tls?: ClientConfigTLS; + grpcMeta?: Record; +} + +/** + * Options for loading client configuration. + * @experimental Environment configuration is new feature and subject to change. + */ +export interface LoadClientConfigOptions { + /** + * If present, this is used as the configuration source instead of default + * file locations. This can be a path or the string/byte contents of the + * configuration file. + */ + configSource?: ConfigDataSource; + /** If true, will error on unrecognized keys in the TOML file. */ + configFileStrict?: boolean; + /** + * The environment variables to use for locating the + * default config file. If not provided, the current process's + * environment is used to check for `TEMPORAL_CONFIG_FILE`. + */ + overrideEnvVars?: Record; +} + +/** + * Client configuration represents a client config file. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export interface ClientConfig { + /** Map of profile name to its corresponding ClientConfigProfile. */ + profiles: Record; +} + +export interface ClientConfigFromTomlOptions { + // If true, will error if there are unrecognized keys. + strict: boolean; +} + +export interface tomlClientConfig { + profile: Record; +} + +export interface tomlClientConfigProfile { + address?: string; + namespace?: string; + api_key?: string; + tls?: tomlClientConfigTLS; + codec?: tomlClientConfigCodec; + grpc_meta?: Record; +} + +export interface tomlClientConfigTLS { + disabled?: boolean; + client_cert_path?: string; + client_cert_data?: string; + client_key_path?: string; + client_key_data?: string; + server_ca_cert_path?: string; + server_ca_cert_data?: string; + server_name?: string; + disable_host_verification?: boolean; +} + +export interface tomlClientConfigCodec { + endpoint?: string; + auth?: string; +} diff --git a/packages/envconfig/src/utils.ts b/packages/envconfig/src/utils.ts new file mode 100644 index 000000000..61e1cd33b --- /dev/null +++ b/packages/envconfig/src/utils.ts @@ -0,0 +1,155 @@ +import * as fs from 'fs'; +import { filterNullAndUndefined } from '@temporalio/common/lib/internal-workflow'; +import { + ClientConfigProfile, + ClientConfigTLS, + ClientConfig, + tomlClientConfigProfile, + tomlClientConfigTLS, + tomlClientConfig, + ConfigDataSource, +} from './types'; + +export function loadConfigData(source?: ConfigDataSource): Buffer | undefined { + if (!source) return undefined; + + if ('path' in source) { + return readPathSync(source.path); + } + + return Buffer.isBuffer(source.data) ? source.data : Buffer.from(source.data); +} + +export function readPathSync(path?: string): Buffer | undefined { + if (path === undefined) { + return undefined; + } + + return fs.readFileSync(path); +} + +export function normalizeGrpcMetaKey(key: string): string { + return key.toLocaleLowerCase().replace('_', '-'); +} + +export function fromTomlProfile(tomlProfile: tomlClientConfigProfile): ClientConfigProfile { + let grpcMeta: Record | undefined = undefined; + if (tomlProfile.grpc_meta !== undefined) { + grpcMeta = {}; + // Normalize GRPC meta keys. + for (const key in tomlProfile.grpc_meta) { + grpcMeta[normalizeGrpcMetaKey(key)] = tomlProfile.grpc_meta[key]; + } + } + const profile: ClientConfigProfile = { + address: tomlProfile.address, + namespace: tomlProfile.namespace, + apiKey: tomlProfile.api_key, + tls: fromTomlTLS(tomlProfile.tls), + grpcMeta, + }; + return filterNullAndUndefined(profile); +} + +export function toTomlProfile(profile: ClientConfigProfile): tomlClientConfigProfile { + let grpc_meta: Record | undefined = undefined; + if (profile.grpcMeta !== undefined) { + grpc_meta = {}; + // Normalize GRPC meta keys. + for (const key in profile.grpcMeta) { + grpc_meta[normalizeGrpcMetaKey(key)] = profile.grpcMeta[key]; + } + } + const tomlProfile = { + address: profile.address, + namespace: profile.namespace, + api_key: profile.apiKey, + tls: toTomlTLS(profile.tls), + grpc_meta, + }; + return filterNullAndUndefined(tomlProfile); +} + +export function fromTomlTLS(tomlTLS?: tomlClientConfigTLS): ClientConfigTLS | undefined { + if (tomlTLS === undefined) { + return undefined; + } + const clientConfigTLS: ClientConfigTLS = { + disabled: tomlTLS.disabled, + serverName: tomlTLS.server_name, + clientCert: toConfigDataSource(tomlTLS.client_cert_path, tomlTLS.client_cert_data, 'client_cert'), + clientKey: toConfigDataSource(tomlTLS.client_key_path, tomlTLS.client_key_data, 'client_key'), + serverCACert: toConfigDataSource(tomlTLS.server_ca_cert_path, tomlTLS.server_ca_cert_data, 'server_ca_cert'), + }; + return filterNullAndUndefined(clientConfigTLS); +} + +export function toTomlTLS(tlsConfig?: ClientConfigTLS): tomlClientConfigTLS | undefined { + if (tlsConfig === undefined) { + return undefined; + } + const clientCert = toPathAndData(tlsConfig.clientCert); + const clientKey = toPathAndData(tlsConfig.clientKey); + const serverCACert = toPathAndData(tlsConfig.serverCACert); + const tomlConfigTLS = { + disabled: tlsConfig.disabled, + server_name: tlsConfig.serverName, + client_cert_path: clientCert?.path, + client_cert_data: clientCert?.data?.toString() ?? undefined, + client_key_path: clientKey?.path, + client_key_data: clientKey?.data?.toString(), + server_ca_cert_path: serverCACert?.path, + server_ca_cert_data: serverCACert?.data?.toString(), + }; + return filterNullAndUndefined(tomlConfigTLS); +} + +export function fromTomlConfig(tomlConfig: tomlClientConfig): ClientConfig { + const profiles: Record = {}; + + for (const [profileName, profile] of Object.entries(tomlConfig.profile)) { + profiles[profileName] = fromTomlProfile(profile); + } + + return { profiles }; +} + +export function toTomlConfig(config: ClientConfig): tomlClientConfig { + const profile: Record = {}; + + for (const [profileName, configProfile] of Object.entries(config.profiles)) { + profile[profileName] = toTomlProfile(configProfile); + } + + return { profile }; +} + +export function toPathAndData(source?: ConfigDataSource): { path?: string; data?: Buffer } | undefined { + if (source === undefined) { + return undefined; + } + if ('path' in source) { + return { path: source.path }; + } + if (Buffer.isBuffer(source.data)) { + return { data: source.data }; + } + return { data: Buffer.from(source.data, 'utf8') }; +} + +export function toConfigDataSource( + path: string | undefined, + data: string | undefined, + fieldName: string +): ConfigDataSource | undefined { + if (path !== undefined && data !== undefined) { + throw new Error(`Cannot specify both ${fieldName}_path and ${fieldName}_data`); + } + if (data !== undefined) { + return { data: Buffer.from(data) }; + } + if (path !== undefined) { + return { path }; + } + return undefined; +} diff --git a/packages/envconfig/tsconfig.json b/packages/envconfig/tsconfig.json new file mode 100644 index 000000000..557f4afc1 --- /dev/null +++ b/packages/envconfig/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./src" + }, + "references": [{ "path": "../common" }, { "path": "../worker" }], + "include": ["./src/**/*.ts"] +} diff --git a/packages/test/package.json b/packages/test/package.json index 8e2d4537a..86cec315f 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -37,6 +37,7 @@ "@temporalio/cloud": "file:../cloud", "@temporalio/common": "file:../common", "@temporalio/core-bridge": "file:../core-bridge", + "@temporalio/envconfig": "file:../envconfig", "@temporalio/interceptors-opentelemetry": "file:../interceptors-opentelemetry", "@temporalio/nexus": "file:../nexus", "@temporalio/nyc-test-coverage": "file:../nyc-test-coverage", diff --git a/packages/test/src/test-envconfig.ts b/packages/test/src/test-envconfig.ts new file mode 100644 index 000000000..7fadd3087 --- /dev/null +++ b/packages/test/src/test-envconfig.ts @@ -0,0 +1,1042 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import test from 'ava'; +import dedent from 'dedent'; +import { Connection, Client } from '@temporalio/client'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { + ClientConfig, + ClientConfigProfile, + ConfigDataSource, + fromTomlConfig, + fromTomlProfile, + loadClientConfig, + loadClientConfigProfile, + loadClientConnectConfig, + toClientOptions, + toTomlConfig, + toTomlProfile, +} from '@temporalio/envconfig'; +import { toPathAndData } from '@temporalio/envconfig/lib/utils'; +import { NativeConnection } from '@temporalio/worker'; + +// Focused TOML fixtures +const TOML_CONFIG_BASE = dedent` + [profile.default] + address = "default-address" + namespace = "default-namespace" + + [profile.custom] + address = "custom-address" + namespace = "custom-namespace" + api_key = "custom-api-key" + [profile.custom.tls] + server_name = "custom-server-name" + [profile.custom.grpc_meta] + custom-header = "custom-value" +`; + +const TOML_CONFIG_STRICT_FAIL = dedent` + [profile.default] + address = "default-address" + unrecognized_field = "should-fail" +`; + +const TOML_CONFIG_TLS_DETAILED = dedent` + [profile.tls_disabled] + address = "localhost:1234" + [profile.tls_disabled.tls] + disabled = true + server_name = "should-be-ignored" + + [profile.tls_with_certs] + address = "localhost:5678" + [profile.tls_with_certs.tls] + server_name = "custom-server" + server_ca_cert_data = "ca-pem-data" + client_cert_data = "client-crt-data" + client_key_data = "client-key-data" +`; + +function withTempFile(content: string, fn: (filepath: string) => void): void { + const tempDir = os.tmpdir(); + const filepath = path.join(tempDir, `temporal-test-config-${Date.now()}-${Math.random()}.toml`); + fs.writeFileSync(filepath, content); + try { + fn(filepath); + } finally { + fs.unlinkSync(filepath); + } +} + +function pathSource(p: string): ConfigDataSource { + return { path: p }; +} +function dataSource(d: Buffer | string): ConfigDataSource { + return { data: typeof d === 'string' ? Buffer.from(d) : d }; +} + +// ============================================================================= +// 🔧 PROFILE LOADING +// ============================================================================= + +test('Load default profile from file', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const profile = loadClientConfigProfile({ configSource: pathSource(filepath) }); + t.is(profile.address, 'default-address'); + t.is(profile.namespace, 'default-namespace'); + t.is(profile.apiKey, undefined); + t.is(profile.tls, undefined); + t.is(profile.grpcMeta, undefined); + + const { connectionOptions, namespace } = toClientOptions(profile); + t.is(connectionOptions.address, 'default-address'); + t.is(namespace, 'default-namespace'); + t.is(connectionOptions.apiKey, undefined); + t.is(connectionOptions.tls, undefined); + t.is(connectionOptions.metadata, undefined); + }); +}); + +test('Load custom profile from file', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const profile = loadClientConfigProfile({ profile: 'custom', configSource: pathSource(filepath) }); + t.is(profile.address, 'custom-address'); + t.is(profile.namespace, 'custom-namespace'); + t.is(profile.apiKey, 'custom-api-key'); + t.truthy(profile.tls); + t.is(profile.tls?.serverName, 'custom-server-name'); + t.is(profile.grpcMeta?.['custom-header'], 'custom-value'); + + const { connectionOptions, namespace } = toClientOptions(profile); + t.is(connectionOptions.address, 'custom-address'); + t.is(namespace, 'custom-namespace'); + t.is(connectionOptions.apiKey, 'custom-api-key'); + const tls1 = connectionOptions.tls; + if (tls1 && typeof tls1 === 'object') { + t.is(tls1.serverNameOverride, 'custom-server-name'); + } else { + t.fail('expected TLS config object'); + } + t.is(connectionOptions.metadata?.['custom-header'], 'custom-value'); + }); +}); + +test('Load default profile from data', (t) => { + const profile = loadClientConfigProfile({ configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)) }); + t.is(profile.address, 'default-address'); + t.is(profile.namespace, 'default-namespace'); + t.is(profile.tls, undefined); +}); + +test('Load custom profile from data', (t) => { + const profile = loadClientConfigProfile({ + profile: 'custom', + configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)), + }); + t.is(profile.address, 'custom-address'); + t.is(profile.namespace, 'custom-namespace'); + t.is(profile.apiKey, 'custom-api-key'); + t.is(profile.tls?.serverName, 'custom-server-name'); +}); + +test('Load profile from data with env overrides', (t) => { + const env = { + TEMPORAL_ADDRESS: 'env-address', + TEMPORAL_NAMESPACE: 'env-namespace', + }; + const profile = loadClientConfigProfile({ + configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)), + overrideEnvVars: env, + }); + t.is(profile.address, 'env-address'); + t.is(profile.namespace, 'env-namespace'); +}); + +test('Load custom profile with env overrides', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const env = { + TEMPORAL_ADDRESS: 'env-address', + TEMPORAL_NAMESPACE: 'env-namespace', + TEMPORAL_API_KEY: 'env-api-key', + TEMPORAL_TLS: 'true', + TEMPORAL_TLS_SERVER_NAME: 'env-server-name', + TEMPORAL_GRPC_META_CUSTOM_HEADER: 'env-value', + TEMPORAL_GRPC_META_ANOTHER_HEADER: 'another-value', + }; + const profile = loadClientConfigProfile({ + profile: 'custom', + configSource: pathSource(filepath), + overrideEnvVars: env, + }); + t.is(profile.address, 'env-address'); + t.is(profile.namespace, 'env-namespace'); + t.is(profile.apiKey, 'env-api-key'); + t.truthy(profile.tls); + t.is(profile.tls?.serverName, 'env-server-name'); + t.is(profile.grpcMeta?.['custom-header'], 'env-value'); + t.is(profile.grpcMeta?.['another-header'], 'another-value'); + }); +}); + +test('Load profiles with string content', (t) => { + const stringContent = TOML_CONFIG_BASE; + const profile = loadClientConfigProfile({ configSource: dataSource(stringContent) }); + t.is(profile.address, 'default-address'); + t.is(profile.namespace, 'default-namespace'); + + // Test with custom profile from string + const profileCustom = loadClientConfigProfile({ + profile: 'custom', + configSource: dataSource(stringContent), + }); + t.is(profileCustom.address, 'custom-address'); + t.is(profileCustom.apiKey, 'custom-api-key'); +}); + +test('loadClientConnectConfig works with file path and env overrides', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + // From file + let cc = loadClientConnectConfig({ configSource: pathSource(filepath) }); + t.is(cc.connectionOptions.address, 'default-address'); + t.is(cc.namespace, 'default-namespace'); + + // With env overrides + cc = loadClientConnectConfig({ + configSource: pathSource(filepath), + overrideEnvVars: { TEMPORAL_NAMESPACE: 'env-namespace-override' }, + }); + t.is(cc.namespace, 'env-namespace-override'); + }); +}); + +// ============================================================================= +// 🌍 ENVIRONMENT VARIABLES +// ============================================================================= + +test('Load profile with grpc metadata env overrides', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.grpc_meta] + original-header = "original-value" + `; + const env = { + TEMPORAL_GRPC_META_NEW_HEADER: 'new-value', + TEMPORAL_GRPC_META_OVERRIDE_HEADER: 'overridden-value', + }; + const profile = loadClientConfigProfile({ + configSource: dataSource(Buffer.from(toml)), + overrideEnvVars: env, + }); + t.is(profile.grpcMeta?.['original-header'], 'original-value'); + t.is(profile.grpcMeta?.['new-header'], 'new-value'); + t.is(profile.grpcMeta?.['override-header'], 'overridden-value'); +}); + +test('gRPC metadata normalization from TOML', (t) => { + const toml = dedent` + [profile.foo] + address = "addr" + [profile.foo.grpc_meta] + sOme-hEader_key = "some-value" + `; + const conf = loadClientConfig({ configSource: dataSource(Buffer.from(toml)) }); + const prof = conf.profiles['foo']; + t.truthy(prof); + t.is(prof.grpcMeta?.['some-header-key'], 'some-value'); +}); + +test('gRPC metadata deletion via empty env value', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.grpc_meta] + some-header = "keep" + remove-me = "to-be-removed" + `; + const env = { + TEMPORAL_GRPC_META_REMOVE_ME: '', + TEMPORAL_GRPC_META_NEW_HEADER: 'added', + }; + const prof = loadClientConfigProfile({ configSource: dataSource(Buffer.from(toml)), overrideEnvVars: env }); + t.is(prof.grpcMeta?.['some-header'], 'keep'); + t.is(prof.grpcMeta?.['new-header'], 'added'); + t.false(Object.prototype.hasOwnProperty.call(prof.grpcMeta, 'remove-me')); +}); + +test('Load profile with disable env flag', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const env = { TEMPORAL_ADDRESS: 'env-address' }; + const profile = loadClientConfigProfile({ + configSource: pathSource(filepath), + overrideEnvVars: env, + disableEnv: true, + }); + t.is(profile.address, 'default-address'); + }); +}); + +// ============================================================================= +// 🎛️ CONTROL FLAGS +// ============================================================================= + +test('Load profile with disabled file flag', (t) => { + const env = { TEMPORAL_ADDRESS: 'env-address', TEMPORAL_NAMESPACE: 'env-namespace' }; + const profile = loadClientConfigProfile({ + configSource: pathSource('/non_existent_file.toml'), + disableFile: true, + overrideEnvVars: env, + }); + t.is(profile.address, 'env-address'); + t.is(profile.namespace, 'env-namespace'); +}); + +test('Load profiles without profile-level env overrides', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const env = { TEMPORAL_ADDRESS: 'should-be-ignored' }; + // loadClientConfig doesn't apply env overrides, so we test it loads correctly + const conf = loadClientConfig({ + configSource: pathSource(filepath), + overrideEnvVars: env, + }); + t.is(conf.profiles['default'].address, 'default-address'); + + // Test that profile-level loading with disableEnv ignores environment + const profile = loadClientConfigProfile({ + configSource: pathSource(filepath), + overrideEnvVars: env, + disableEnv: true, + }); + t.is(profile.address, 'default-address'); + }); +}); + +test('Cannot disable both file and env override flags', (t) => { + const err = t.throws(() => + loadClientConfigProfile({ + configSource: pathSource('/non_existent_file.toml'), + disableFile: true, + disableEnv: true, + }) + ); + t.truthy(err); + t.true(String(err?.message).includes('Cannot disable both')); +}); + +// ============================================================================= +// 📁 CONFIG DISCOVERY +// ============================================================================= + +test('Load all profiles from file', (t) => { + const conf = loadClientConfig({ configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)) }); + t.truthy(conf.profiles['default']); + t.truthy(conf.profiles['custom']); + t.is(conf.profiles['default'].address, 'default-address'); + t.is(conf.profiles['custom'].apiKey, 'custom-api-key'); +}); + +test('Load all profiles from data', (t) => { + const configData = dedent` + [profile.alpha] + address = "alpha-address" + namespace = "alpha-namespace" + + [profile.beta] + address = "beta-address" + api_key = "beta-key" + `; + const conf = loadClientConfig({ configSource: dataSource(Buffer.from(configData)) }); + t.truthy(conf.profiles['alpha']); + t.truthy(conf.profiles['beta']); + t.is(conf.profiles['alpha'].address, 'alpha-address'); + t.is(conf.profiles['beta'].apiKey, 'beta-key'); +}); + +test('Load profiles from non-existent file', (t) => { + const conf = loadClientConfig({ + configSource: pathSource('/non_existent_file.toml'), + }); + t.deepEqual(conf.profiles, {}); +}); + +test('Load all profiles with overridden file path', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const conf = loadClientConfig({ overrideEnvVars: { TEMPORAL_CONFIG_FILE: filepath } }); + t.truthy(conf.profiles['default']); + t.is(conf.profiles['default'].address, 'default-address'); + }); +}); + +test('Default profile not found returns empty profile', (t) => { + const toml = dedent` + [profile.existing] + address = "my-address" + `; + const prof = loadClientConfigProfile({ configSource: dataSource(Buffer.from(toml)) }); + t.is(prof.address, undefined); + t.is(prof.namespace, undefined); + t.is(prof.apiKey, undefined); + t.is(prof.grpcMeta, undefined); + t.is(prof.tls, undefined); + t.deepEqual(prof, {}); +}); + +// ============================================================================= +// 🔐 TLS CONFIGURATION +// ============================================================================= + +test('Load profile with api key (enables TLS)', (t) => { + const toml = dedent` + [profile.default] + address = "my-address" + api_key = "my-api-key" + `; + const profile = loadClientConfigProfile({ configSource: dataSource(Buffer.from(toml)) }); + t.is(profile.tls, undefined); + t.is(profile.tls?.disabled, undefined); + const { connectionOptions } = toClientOptions(profile); + t.true(connectionOptions.tls); +}); + +test('Load profile with TLS options', (t) => { + const configSource = dataSource(Buffer.from(TOML_CONFIG_TLS_DETAILED)); + + const profileDisabled = loadClientConfigProfile({ configSource, profile: 'tls_disabled' }); + t.truthy(profileDisabled.tls); + t.true(profileDisabled.tls?.disabled); + t.is(profileDisabled.tls?.serverName, 'should-be-ignored'); + const { connectionOptions: connOptsDisabled } = toClientOptions(profileDisabled); + t.is(connOptsDisabled.tls, false); + + const profileCerts = loadClientConfigProfile({ configSource, profile: 'tls_with_certs' }); + t.truthy(profileCerts.tls); + t.is(profileCerts.tls?.serverName, 'custom-server'); + + const serverCACert = toPathAndData(profileCerts.tls?.serverCACert); + t.deepEqual(serverCACert?.data, Buffer.from('ca-pem-data')); + t.is(serverCACert?.path, undefined); + + const clientCert = toPathAndData(profileCerts.tls?.clientCert); + t.deepEqual(clientCert?.data, Buffer.from('client-crt-data')); + t.is(clientCert?.path, undefined); + + const clientKey = toPathAndData(profileCerts.tls?.clientKey); + t.deepEqual(clientKey?.data, Buffer.from('client-key-data')); + t.is(clientKey?.path, undefined); + + const { connectionOptions: connOptsCerts } = toClientOptions(profileCerts); + const tls2 = connOptsCerts.tls; + if (tls2 && typeof tls2 === 'object') { + t.is(tls2.serverNameOverride, 'custom-server'); + t.deepEqual(tls2.serverRootCACertificate, Buffer.from('ca-pem-data')); + t.deepEqual(tls2.clientCertPair?.crt, Buffer.from('client-crt-data')); + t.deepEqual(tls2.clientCertPair?.key, Buffer.from('client-key-data')); + } else { + t.fail('expected TLS config object'); + } +}); + +test('Load profile with TLS options as file paths', (t) => { + withTempFile('ca-pem-data', (caPath) => { + withTempFile('client-crt-data', (certPath) => { + withTempFile('client-key-data', (keyPath) => { + const tomlConfig = dedent` + [profile.default] + address = "localhost:5678" + [profile.default.tls] + server_name = "custom-server" + server_ca_cert_path = "${caPath}" + client_cert_path = "${certPath}" + client_key_path = "${keyPath}" + `; + const profile = loadClientConfigProfile({ configSource: dataSource(Buffer.from(tomlConfig)) }); + t.truthy(profile.tls); + t.is(profile.tls?.serverName, 'custom-server'); + + const serverCACert = toPathAndData(profile.tls?.serverCACert); + t.is(serverCACert?.data, undefined); + t.deepEqual(serverCACert?.path, caPath); + + const clientCert = toPathAndData(profile.tls?.clientCert); + t.is(clientCert?.data, undefined); + t.deepEqual(clientCert?.path, certPath); + + const clientKey = toPathAndData(profile.tls?.clientKey); + t.is(clientKey?.data, undefined); + t.deepEqual(clientKey?.path, keyPath); + + const { connectionOptions: connOpts } = toClientOptions(profile); + const tls3 = connOpts.tls; + if (tls3 && typeof tls3 === 'object') { + t.is(tls3.serverNameOverride, 'custom-server'); + t.deepEqual(tls3.serverRootCACertificate, Buffer.from('ca-pem-data')); + t.deepEqual(tls3.clientCertPair?.crt, Buffer.from('client-crt-data')); + t.deepEqual(tls3.clientCertPair?.key, Buffer.from('client-key-data')); + } else { + t.fail('expected TLS config object'); + } + }); + }); + }); +}); + +test('Load profile with conflicting cert source fails', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.tls] + client_cert_path = "some-path" + client_cert_data = "some-data" + `; + const err = t.throws(() => loadClientConfigProfile({ configSource: dataSource(Buffer.from(toml)) })); + t.truthy(err); + t.true(String(err?.message).includes('Cannot specify both')); +}); + +test('TLS conflict across sources: path in TOML, data in env should error', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.tls] + client_cert_path = "some-path" + `; + const env = { TEMPORAL_TLS_CLIENT_CERT_DATA: 'some-data' }; + const err = t.throws(() => + loadClientConfigProfile({ configSource: dataSource(Buffer.from(toml)), overrideEnvVars: env }) + ); + t.truthy(err); + t.true( + String(err?.message) + .toLowerCase() + .includes('path') + ); +}); + +test('TLS conflict across sources: data in TOML, path in env should error', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.tls] + client_cert_data = "some-data" + `; + const env = { TEMPORAL_TLS_CLIENT_CERT_PATH: 'some-path' }; + const err = t.throws(() => + loadClientConfigProfile({ configSource: dataSource(Buffer.from(toml)), overrideEnvVars: env }) + ); + t.truthy(err); + t.true( + String(err?.message) + .toLowerCase() + .includes('data') + ); +}); + +test('TLS disabled tri-state behavior', (t) => { + // Test 1: disabled=null (unset) with API key -> TLS enabled + const tomlNull = dedent` + [profile.default] + address = "my-address" + api_key = "my-api-key" + [profile.default.tls] + server_name = "my-server" + `; + const profileNull = loadClientConfigProfile({ configSource: dataSource(Buffer.from(tomlNull)) }); + t.truthy(profileNull.tls); + t.is(profileNull.tls?.disabled, undefined); // disabled is null (unset) + const configNull = toClientOptions(profileNull); + t.truthy(configNull.connectionOptions.tls); // TLS enabled + + // Test 2: disabled=false (explicitly enabled) -> TLS enabled + const tomlFalse = dedent` + [profile.default] + address = "my-address" + [profile.default.tls] + disabled = false + server_name = "my-server" + `; + const profileFalse = loadClientConfigProfile({ configSource: dataSource(Buffer.from(tomlFalse)) }); + t.truthy(profileFalse.tls); + t.is(profileFalse.tls?.disabled, false); // explicitly disabled=false + const configFalse = toClientOptions(profileFalse); + t.truthy(configFalse.connectionOptions.tls); // TLS enabled + + // Test 3: disabled=true (explicitly disabled) -> TLS disabled even with API key + const tomlTrue = dedent` + [profile.default] + address = "my-address" + api_key = "my-api-key" + [profile.default.tls] + disabled = true + server_name = "should-be-ignored" + `; + const profileTrue = loadClientConfigProfile({ configSource: dataSource(Buffer.from(tomlTrue)) }); + t.truthy(profileTrue.tls); + t.is(profileTrue.tls?.disabled, true); // explicitly disabled=true + const configTrue = toClientOptions(profileTrue); + t.is(configTrue.connectionOptions.tls, false); // TLS explicitly disabled even with API key +}); + +// ============================================================================= +// 🚫 ERROR HANDLING +// ============================================================================= + +test('Load non-existent profile', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const err = t.throws(() => loadClientConfigProfile({ configSource: pathSource(filepath), profile: 'nonexistent' })); + t.truthy(err); + t.true(String(err?.message).includes("Profile 'nonexistent' not found")); + }); +}); + +test('Load invalid config with strict mode enabled', (t) => { + const toml = dedent` + [unrecognized_table] + foo = "bar" + `; + const err = t.throws(() => loadClientConfig({ configSource: dataSource(Buffer.from(toml)), configFileStrict: true })); + t.truthy(err); + t.true(String(err?.message).includes('unrecognized_table')); +}); + +test('Load invalid profile with strict mode enabled', (t) => { + withTempFile(TOML_CONFIG_STRICT_FAIL, (filepath) => { + const err = t.throws(() => loadClientConfigProfile({ configSource: pathSource(filepath), configFileStrict: true })); + t.truthy(err); + t.true(String(err?.message).includes('unrecognized_field')); + }); +}); + +test('Load profiles with malformed TOML', (t) => { + const err = t.throws(() => loadClientConfig({ configSource: dataSource(Buffer.from('this is not valid toml')) })); + t.truthy(err); + t.true( + String(err?.message) + .toLowerCase() + .includes('toml') + ); +}); + +// ============================================================================= +// 🔄 SERIALIZATION +// ============================================================================= + +test('Client config profile to/from TOML round-trip', (t) => { + const profile: ClientConfigProfile = { + address: 'some-address', + namespace: 'some-namespace', + apiKey: 'some-api-key', + tls: { + serverName: 'some-server', + serverCACert: { data: Buffer.from('ca') }, + clientCert: { path: '/path/to/client.crt' }, + clientKey: { data: Buffer.from('key') }, + }, + grpcMeta: { 'some-header': 'some-value' }, + }; + const tomlProfile = toTomlProfile(profile); + const back = fromTomlProfile(tomlProfile); + t.is(back.address, 'some-address'); + t.is(back.namespace, 'some-namespace'); + t.is(back.apiKey, 'some-api-key'); + t.truthy(back.tls); + t.is(back.tls?.serverName, 'some-server'); + + const serverCACert = toPathAndData(back.tls?.serverCACert); + t.deepEqual(serverCACert?.data, Buffer.from('ca')); + t.is(serverCACert?.path, undefined); + + const clientCert = toPathAndData(back.tls?.clientCert); + t.is(clientCert?.data, undefined); + t.deepEqual(clientCert?.path, '/path/to/client.crt'); + + const clientKey = toPathAndData(back.tls?.clientKey); + t.deepEqual(clientKey?.data, Buffer.from('key')); + t.is(clientKey?.path, undefined); + + t.is(back.grpcMeta?.['some-header'], 'some-value'); +}); + +test('Client config to/from TOML round-trip', (t) => { + const conf: ClientConfig = { + profiles: { + default: { address: 'addr', namespace: 'ns', grpcMeta: {} }, + custom: { address: 'addr2', apiKey: 'key2', grpcMeta: { h: 'v' } }, + }, + }; + const tomlConfig = toTomlConfig(conf); + const back = fromTomlConfig(tomlConfig); + t.is(back.profiles['default'].address, 'addr'); + t.is(back.profiles['default'].namespace, 'ns'); + t.is(back.profiles['custom'].address, 'addr2'); + t.is(back.profiles['custom'].apiKey, 'key2'); + t.is(back.profiles['custom'].grpcMeta?.['h'], 'v'); +}); + +// ============================================================================= +// 🎯 INTEGRATION/E2E +// ============================================================================= + +test('Create client with default profile, no config', async (t) => { + // Start a local test server + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + // Load config via envconfig + const { connectionOptions, namespace } = loadClientConnectConfig(); + // Override address with test env address. + connectionOptions.address = address; + + // Create connection and client with loaded config + const connection = await Connection.connect(connectionOptions); + const client = new Client({ + connection, + namespace, + }); + + // If we got here without throwing, the connection is working + t.truthy(client); + t.truthy(client.connection); + + // Clean up + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create client from default profile', async (t) => { + // Start a local test server + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Create TOML config with test server address + const toml = dedent` + [profile.default] + address = "${address}" + namespace = "default" + `; + + // Load config via envconfig + const { connectionOptions, namespace } = loadClientConnectConfig({ + configSource: dataSource(Buffer.from(toml)), + }); + + // Verify loaded values + t.is(connectionOptions.address, address); + t.is(namespace, 'default'); + + // Create connection and client with loaded config + const connection = await Connection.connect(connectionOptions); + const client = new Client({ + connection, + namespace: namespace || 'default', + }); + + // If we got here without throwing, the connection is working + t.truthy(client); + t.truthy(client.connection); + + // Clean up + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create client with NativeConnection from default profile', async (t) => { + // Start a local test server + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Create TOML config with test server address + const toml = dedent` + [profile.default] + address = "${address}" + namespace = "default" + `; + + // Load config via envconfig + const { connectionOptions, namespace } = loadClientConnectConfig({ + configSource: dataSource(Buffer.from(toml)), + }); + + // Verify loaded values + t.is(connectionOptions.address, address); + t.is(namespace, 'default'); + + // Create connection and client with loaded config + const connection = await NativeConnection.connect(connectionOptions); + const client = new Client({ + connection, + namespace: namespace || 'default', + }); + + // If we got here without throwing, the connection is working + t.truthy(client); + t.truthy(client.connection); + + // Clean up + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create client from custom profile', async (t) => { + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Create basic development profile configuration + const toml = dedent` + [profile.development] + address = "${address}" + namespace = "development-namespace" + `; + + // Load profile and create connection + const profile = loadClientConfigProfile({ + profile: 'development', + configSource: dataSource(Buffer.from(toml)), + }); + + t.is(profile.address, address); + t.is(profile.namespace, 'development-namespace'); + t.is(profile.apiKey, undefined); + t.is(profile.tls, undefined); + + const { connectionOptions, namespace } = toClientOptions(profile); + const connection = await Connection.connect(connectionOptions); + const client = new Client({ connection, namespace: namespace || 'default' }); + + // Verify the client can perform basic operations + t.truthy(client); + t.truthy(client.connection); + t.is(client.options.namespace, 'development-namespace'); + + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create client from custom profile with TLS options', async (t) => { + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Create production profile with API key (auto-enables TLS but disabled for local test) + const toml = dedent` + [profile.production] + address = "${address}" + namespace = "production-namespace" + api_key = "prod-api-key-12345" + [profile.production.tls] + disabled = true + `; + + // Load profile and verify TLS/API key handling + const profile = loadClientConfigProfile({ + profile: 'production', + configSource: dataSource(Buffer.from(toml)), + }); + + t.is(profile.address, address); + t.is(profile.namespace, 'production-namespace'); + t.is(profile.apiKey, 'prod-api-key-12345'); + t.truthy(profile.tls); + t.true(!!profile.tls?.disabled); + + const { connectionOptions, namespace } = toClientOptions(profile); + + // Verify API key is present but TLS is disabled for local testing + t.is(connectionOptions.apiKey, 'prod-api-key-12345'); + t.is(connectionOptions.tls, false); // disabled = true results in tls being false + + const connection = await Connection.connect(connectionOptions); + const client = new Client({ connection, namespace: namespace || 'default' }); + + t.truthy(client); + t.is(client.options.namespace, 'production-namespace'); + + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create client from default profile with env overrides', async (t) => { + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Base config that will be overridden by environment + const toml = dedent` + [profile.default] + address = "original-address" + namespace = "original-namespace" + `; + + // Environment overrides + const envOverrides = { + TEMPORAL_ADDRESS: address, // Override with test server address + TEMPORAL_NAMESPACE: 'env-override-namespace', + TEMPORAL_GRPC_META_CUSTOM_HEADER: 'env-header-value', + }; + + // Load profile with environment overrides + const profile = loadClientConfigProfile({ + configSource: dataSource(Buffer.from(toml)), + overrideEnvVars: envOverrides, + }); + + // Verify environment variables took precedence + t.is(profile.address, address); + t.is(profile.namespace, 'env-override-namespace'); + t.is(profile.grpcMeta?.['custom-header'], 'env-header-value'); + + const { connectionOptions, namespace } = toClientOptions(profile); + const connection = await Connection.connect(connectionOptions); + const client = new Client({ connection, namespace: namespace || 'default' }); + + // Verify client uses overridden values + t.truthy(client); + t.is(client.options.namespace, 'env-override-namespace'); + t.is(connectionOptions.metadata?.['custom-header'], 'env-header-value'); + + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create clients from multi-profile config', async (t) => { + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Multi-profile configuration + const toml = dedent` + [profile.service-a] + address = "${address}" + namespace = "service-a-namespace" + [profile.service-a.grpc_meta] + service-name = "service-a" + + [profile.service-b] + address = "${address}" + namespace = "service-b-namespace" + [profile.service-b.grpc_meta] + service-name = "service-b" + priority = "high" + `; + + // Load different profiles and create separate clients + const profileA = loadClientConfigProfile({ + profile: 'service-a', + configSource: dataSource(Buffer.from(toml)), + }); + + const profileB = loadClientConfigProfile({ + profile: 'service-b', + configSource: dataSource(Buffer.from(toml)), + }); + + // Verify profiles are distinct + t.is(profileA.namespace, 'service-a-namespace'); + t.is(profileA.grpcMeta?.['service-name'], 'service-a'); + t.false('priority' in (profileA.grpcMeta ?? {})); + + t.is(profileB.namespace, 'service-b-namespace'); + t.is(profileB.grpcMeta?.['service-name'], 'service-b'); + t.is(profileB.grpcMeta?.['priority'], 'high'); + + // Create separate client connections + const configA = toClientOptions(profileA); + const configB = toClientOptions(profileB); + + const connectionA = await Connection.connect(configA.connectionOptions); + const connectionB = await Connection.connect(configB.connectionOptions); + + const clientA = new Client({ connection: connectionA, namespace: configA.namespace || 'default' }); + const clientB = new Client({ connection: connectionB, namespace: configB.namespace || 'default' }); + + // Verify both clients work with their respective configurations + t.truthy(clientA); + t.truthy(clientB); + t.is(clientA.options.namespace, 'service-a-namespace'); + t.is(clientB.options.namespace, 'service-b-namespace'); + + // Verify metadata is correctly set for each connection + t.is(configA.connectionOptions.metadata?.['service-name'], 'service-a'); + t.is(configB.connectionOptions.metadata?.['service-name'], 'service-b'); + t.is(configB.connectionOptions.metadata?.['priority'], 'high'); + + await connectionA.close(); + await connectionB.close(); + } finally { + await env.teardown(); + } +}); + +test('Comprehensive E2E validation test', (t) => { + // Test comprehensive end-to-end configuration loading with all features + const tomlContent = dedent` + [profile.production] + address = "prod.temporal.com:443" + namespace = "production-ns" + api_key = "prod-api-key" + + [profile.production.tls] + server_name = "prod.temporal.com" + server_ca_cert_data = "prod-ca-cert" + + [profile.production.grpc_meta] + authorization = "Bearer prod-token" + "x-custom-header" = "prod-value" + `; + + const envOverrides = { + TEMPORAL_GRPC_META_X_ENVIRONMENT: 'production', + TEMPORAL_TLS_SERVER_NAME: 'override.temporal.com', + }; + + const { connectionOptions, namespace } = loadClientConnectConfig({ + profile: 'production', + configSource: dataSource(Buffer.from(tomlContent)), + overrideEnvVars: envOverrides, + }); + + // Validate all configuration aspects + t.is(connectionOptions.address, 'prod.temporal.com:443'); + t.is(namespace, 'production-ns'); + t.is(connectionOptions.apiKey, 'prod-api-key'); + + // TLS configuration (API key should auto-enable TLS) + t.truthy(connectionOptions.tls); + const tls = connectionOptions.tls; + if (tls && typeof tls === 'object') { + t.is(tls.serverNameOverride, 'override.temporal.com'); // Env override + t.deepEqual(tls.serverRootCACertificate, Buffer.from('prod-ca-cert')); + } else { + t.fail('expected TLS config object'); + } + + // gRPC metadata with normalization and env overrides + t.truthy(connectionOptions.metadata); + const metadata = connectionOptions.metadata!; + t.is(metadata['authorization'], 'Bearer prod-token'); + t.is(metadata['x-custom-header'], 'prod-value'); + t.is(metadata['x-environment'], 'production'); // From env +}); diff --git a/packages/test/tsconfig.json b/packages/test/tsconfig.json index 3e55850d4..f9985819b 100644 --- a/packages/test/tsconfig.json +++ b/packages/test/tsconfig.json @@ -12,6 +12,7 @@ { "path": "../client" }, { "path": "../cloud" }, { "path": "../common" }, + { "path": "../envconfig" }, { "path": "../interceptors-opentelemetry" }, { "path": "../nexus" }, { "path": "../testing" }, diff --git a/tsconfig.prune.json b/tsconfig.prune.json index e531d3546..edb96b374 100644 --- a/tsconfig.prune.json +++ b/tsconfig.prune.json @@ -12,6 +12,7 @@ "./packages/common/src/index.ts", "./packages/common/src/type-helpers.ts", "./packages/create-project/src/index.ts", + "./packages/envconfig/src/index.ts", "./packages/interceptors-opentelemetry/src/index.ts", "./packages/nyc-test-coverage/src/index.ts", "./packages/meta/src/index.ts", From eb332c43048c8e0a1eaa4bd09fc610e943ec9af6 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 16 Oct 2025 13:09:40 -0700 Subject: [PATCH 2/7] minor suggestions and CI fixes --- packages/envconfig/src/envconfig-toml.ts | 53 ++++++++++-------------- packages/envconfig/src/types.ts | 22 ++++------ packages/envconfig/src/utils.ts | 32 ++++++-------- packages/test/src/test-envconfig.ts | 11 +++-- 4 files changed, 50 insertions(+), 68 deletions(-) diff --git a/packages/envconfig/src/envconfig-toml.ts b/packages/envconfig/src/envconfig-toml.ts index bdd36aa50..f0daf8435 100644 --- a/packages/envconfig/src/envconfig-toml.ts +++ b/packages/envconfig/src/envconfig-toml.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'fs'; import * as path from 'path'; import * as os from 'os'; import { parse, stringify, TomlTable } from 'smol-toml'; @@ -5,19 +6,19 @@ import { ConfigDataSource, LoadClientConfigOptions, LoadClientProfileOptions, - tomlClientConfig, - tomlClientConfigCodec, - tomlClientConfigProfile, - tomlClientConfigTLS, + TomlClientConfig, + TomlClientConfigCodec, + TomlClientConfigProfile, + TomlClientConfigTLS, } from './types'; -import { normalizeGrpcMetaKey, readPathSync } from './utils'; +import { normalizeGrpcMetaKey } from './utils'; function sourceToStringData(source?: ConfigDataSource): string | undefined { if (source === undefined) { return undefined; } if ('path' in source) { - return readPathSync(source.path)?.toString(); + return readFileSync(source.path)?.toString(); } if (Buffer.isBuffer(source.data)) { @@ -35,7 +36,7 @@ function getEnvVar(provider: EnvProvider, key: string): string | undefined { return process.env[key]; } -export function tomlLoadClientConfig(options: LoadClientConfigOptions): tomlClientConfig { +export function tomlLoadClientConfig(options: LoadClientConfigOptions): TomlClientConfig { const envProvider: EnvProvider = options.overrideEnvVars ? { kind: 'Map', map: options.overrideEnvVars } : { kind: 'System' }; @@ -56,16 +57,16 @@ export function tomlLoadClientConfig(options: LoadClientConfigOptions): tomlClie return { profile: {} }; // default ClientConfig } -export function loadFromTomlData(tomlData: string, isStrict: boolean): tomlClientConfig { +export function loadFromTomlData(tomlData: string, isStrict: boolean): TomlClientConfig { const parsed = parse(tomlData); if (isStrict) { strictValidateTomlStructure(parsed); } - return parsed as unknown as tomlClientConfig; + return parsed as unknown as TomlClientConfig; } -export function configToTomlData(config: tomlClientConfig): Buffer { +export function configToTomlData(config: TomlClientConfig): Buffer { return Buffer.from(stringify(config), 'utf-8'); } @@ -145,10 +146,10 @@ function getFallbackConfigData(envProvider: EnvProvider): string | undefined { if (filePath === undefined) { filePath = getDefaultConfigFilePath(); } - return readPathSync(filePath)?.toString(); + return readFileSync(filePath).toString(); } -export function tomlLoadClientConfigProfile(options: LoadClientProfileOptions): tomlClientConfigProfile { +export function tomlLoadClientConfigProfile(options: LoadClientProfileOptions): TomlClientConfigProfile { if (options.disableEnv && options.disableFile) { throw new Error('Cannot disable both file and environment loading'); } @@ -157,7 +158,7 @@ export function tomlLoadClientConfigProfile(options: LoadClientProfileOptions): ? { kind: 'Map', map: options.overrideEnvVars } : { kind: 'System' }; - let profile: tomlClientConfigProfile = {}; + let profile: TomlClientConfigProfile = {}; if (!options.disableFile) { const tomlClientConfig = tomlLoadClientConfig({ @@ -165,20 +166,12 @@ export function tomlLoadClientConfigProfile(options: LoadClientProfileOptions): configFileStrict: options.configFileStrict, overrideEnvVars: options.overrideEnvVars, }); - let profileName = options.profile; - let profileSet = true; // If profile name not provided, fallback to env variable. - if (profileName === undefined) { - profileName = getEnvVar(envProvider, 'TEMPORAL_PROFILE'); - } + const profileName = options.profile ?? getEnvVar(envProvider, 'TEMPORAL_PROFILE'); // If env var also not provided, fallback to default profile name. - if (profileName === undefined) { - profileName = DEFAULT_CONFIG_FILE_PROFILE; - profileSet = false; - } - const tomlProfile = tomlClientConfig.profile[profileName]; + const tomlProfile = tomlClientConfig.profile[profileName ?? DEFAULT_CONFIG_FILE_PROFILE]; // If toml profile does not exist and an explicit profile was requested, error. - if (tomlProfile === undefined && profileSet) { + if (tomlProfile === undefined && profileName) { throw new Error(`Profile '${profileName}' not found in config data`); } // Use loaded profile if exists, otherwise fallback to default profile. @@ -191,7 +184,7 @@ export function tomlLoadClientConfigProfile(options: LoadClientProfileOptions): return profile; } -function applyProfileEnvVars(profile: tomlClientConfigProfile, envProvider: EnvProvider) { +function applyProfileEnvVars(profile: TomlClientConfigProfile, envProvider: EnvProvider) { profile.address = getEnvVar(envProvider, 'TEMPORAL_ADDRESS') ?? profile.address; profile.namespace = getEnvVar(envProvider, 'TEMPORAL_NAMESPACE') ?? profile.namespace; profile.api_key = getEnvVar(envProvider, 'TEMPORAL_API_KEY') ?? profile.api_key; @@ -202,8 +195,8 @@ function applyProfileEnvVars(profile: tomlClientConfigProfile, envProvider: EnvP applyGrpcMetaFromEnvVars(profile, envProvider); } -function getTLSFromEnvVars(envProvider: EnvProvider): tomlClientConfigTLS | undefined { - const tlsConfig: tomlClientConfigTLS = {}; +function getTLSFromEnvVars(envProvider: EnvProvider): TomlClientConfigTLS | undefined { + const tlsConfig: TomlClientConfigTLS = {}; const tlsEnvVar = getEnvVar(envProvider, 'TEMPORAL_TLS'); if (tlsEnvVar !== undefined) { tlsConfig.disabled = envVarToBool(tlsEnvVar); @@ -244,8 +237,8 @@ function getTLSFromEnvVars(envProvider: EnvProvider): tomlClientConfigTLS | unde return Object.keys(tlsConfig).length > 0 ? tlsConfig : undefined; } -function getCodecFromEnvVars(envProvider: EnvProvider): tomlClientConfigCodec | undefined { - const codec: tomlClientConfigCodec = {}; +function getCodecFromEnvVars(envProvider: EnvProvider): TomlClientConfigCodec | undefined { + const codec: TomlClientConfigCodec = {}; const endpoint = getEnvVar(envProvider, 'TEMPORAL_CODEC_ENDPOINT'); if (endpoint !== undefined) { codec.endpoint = endpoint; @@ -258,7 +251,7 @@ function getCodecFromEnvVars(envProvider: EnvProvider): tomlClientConfigCodec | return Object.keys(codec).length > 0 ? codec : undefined; } -function applyGrpcMetaFromEnvVars(profile: tomlClientConfigProfile, envProvider: EnvProvider) { +function applyGrpcMetaFromEnvVars(profile: TomlClientConfigProfile, envProvider: EnvProvider) { const PREFIX = 'TEMPORAL_GRPC_META_'; // Get key-value pairs based on provider type diff --git a/packages/envconfig/src/types.ts b/packages/envconfig/src/types.ts index 2668095c7..1edd37696 100644 --- a/packages/envconfig/src/types.ts +++ b/packages/envconfig/src/types.ts @@ -24,14 +24,6 @@ export interface ClientConnectConfig { namespace?: string; } -export interface ClientConfigProfileOptions { - address?: string; - namespace?: string; - apiKey?: string; - tls?: ClientConfigTLS; - grpcMeta?: Record; -} - /** * Options for loading a client configuration profile. * @@ -106,20 +98,20 @@ export interface ClientConfigFromTomlOptions { strict: boolean; } -export interface tomlClientConfig { - profile: Record; +export interface TomlClientConfig { + profile: Record; } -export interface tomlClientConfigProfile { +export interface TomlClientConfigProfile { address?: string; namespace?: string; api_key?: string; - tls?: tomlClientConfigTLS; - codec?: tomlClientConfigCodec; + tls?: TomlClientConfigTLS; + codec?: TomlClientConfigCodec; grpc_meta?: Record; } -export interface tomlClientConfigTLS { +export interface TomlClientConfigTLS { disabled?: boolean; client_cert_path?: string; client_cert_data?: string; @@ -131,7 +123,7 @@ export interface tomlClientConfigTLS { disable_host_verification?: boolean; } -export interface tomlClientConfigCodec { +export interface TomlClientConfigCodec { endpoint?: string; auth?: string; } diff --git a/packages/envconfig/src/utils.ts b/packages/envconfig/src/utils.ts index 61e1cd33b..f3132c66f 100644 --- a/packages/envconfig/src/utils.ts +++ b/packages/envconfig/src/utils.ts @@ -1,12 +1,12 @@ -import * as fs from 'fs'; +import { readFileSync } from 'fs'; import { filterNullAndUndefined } from '@temporalio/common/lib/internal-workflow'; import { ClientConfigProfile, ClientConfigTLS, ClientConfig, - tomlClientConfigProfile, - tomlClientConfigTLS, - tomlClientConfig, + TomlClientConfigProfile, + TomlClientConfigTLS, + TomlClientConfig, ConfigDataSource, } from './types'; @@ -14,25 +14,17 @@ export function loadConfigData(source?: ConfigDataSource): Buffer | undefined { if (!source) return undefined; if ('path' in source) { - return readPathSync(source.path); + return readFileSync(source.path); } return Buffer.isBuffer(source.data) ? source.data : Buffer.from(source.data); } -export function readPathSync(path?: string): Buffer | undefined { - if (path === undefined) { - return undefined; - } - - return fs.readFileSync(path); -} - export function normalizeGrpcMetaKey(key: string): string { return key.toLocaleLowerCase().replace('_', '-'); } -export function fromTomlProfile(tomlProfile: tomlClientConfigProfile): ClientConfigProfile { +export function fromTomlProfile(tomlProfile: TomlClientConfigProfile): ClientConfigProfile { let grpcMeta: Record | undefined = undefined; if (tomlProfile.grpc_meta !== undefined) { grpcMeta = {}; @@ -51,7 +43,7 @@ export function fromTomlProfile(tomlProfile: tomlClientConfigProfile): ClientCon return filterNullAndUndefined(profile); } -export function toTomlProfile(profile: ClientConfigProfile): tomlClientConfigProfile { +export function toTomlProfile(profile: ClientConfigProfile): TomlClientConfigProfile { let grpc_meta: Record | undefined = undefined; if (profile.grpcMeta !== undefined) { grpc_meta = {}; @@ -70,7 +62,7 @@ export function toTomlProfile(profile: ClientConfigProfile): tomlClientConfigPro return filterNullAndUndefined(tomlProfile); } -export function fromTomlTLS(tomlTLS?: tomlClientConfigTLS): ClientConfigTLS | undefined { +export function fromTomlTLS(tomlTLS?: TomlClientConfigTLS): ClientConfigTLS | undefined { if (tomlTLS === undefined) { return undefined; } @@ -84,7 +76,7 @@ export function fromTomlTLS(tomlTLS?: tomlClientConfigTLS): ClientConfigTLS | un return filterNullAndUndefined(clientConfigTLS); } -export function toTomlTLS(tlsConfig?: ClientConfigTLS): tomlClientConfigTLS | undefined { +export function toTomlTLS(tlsConfig?: ClientConfigTLS): TomlClientConfigTLS | undefined { if (tlsConfig === undefined) { return undefined; } @@ -104,7 +96,7 @@ export function toTomlTLS(tlsConfig?: ClientConfigTLS): tomlClientConfigTLS | un return filterNullAndUndefined(tomlConfigTLS); } -export function fromTomlConfig(tomlConfig: tomlClientConfig): ClientConfig { +export function fromTomlConfig(tomlConfig: TomlClientConfig): ClientConfig { const profiles: Record = {}; for (const [profileName, profile] of Object.entries(tomlConfig.profile)) { @@ -114,8 +106,8 @@ export function fromTomlConfig(tomlConfig: tomlClientConfig): ClientConfig { return { profiles }; } -export function toTomlConfig(config: ClientConfig): tomlClientConfig { - const profile: Record = {}; +export function toTomlConfig(config: ClientConfig): TomlClientConfig { + const profile: Record = {}; for (const [profileName, configProfile] of Object.entries(config.profiles)) { profile[profileName] = toTomlProfile(configProfile); diff --git a/packages/test/src/test-envconfig.ts b/packages/test/src/test-envconfig.ts index 7fadd3087..33c9e703e 100644 --- a/packages/test/src/test-envconfig.ts +++ b/packages/test/src/test-envconfig.ts @@ -442,14 +442,19 @@ test('Load profile with TLS options as file paths', (t) => { withTempFile('ca-pem-data', (caPath) => { withTempFile('client-crt-data', (certPath) => { withTempFile('client-key-data', (keyPath) => { + // Normalize paths to use forward slashes for TOML compatibility (Windows uses backslashes) + const normalizedCaPath = caPath.replace(/\\/g, '/'); + const normalizedCertPath = certPath.replace(/\\/g, '/'); + const normalizedKeyPath = keyPath.replace(/\\/g, '/'); + const tomlConfig = dedent` [profile.default] address = "localhost:5678" [profile.default.tls] server_name = "custom-server" - server_ca_cert_path = "${caPath}" - client_cert_path = "${certPath}" - client_key_path = "${keyPath}" + server_ca_cert_path = "${normalizedCaPath}" + client_cert_path = "${normalizedCertPath}" + client_key_path = "${normalizedKeyPath}" `; const profile = loadClientConfigProfile({ configSource: dataSource(Buffer.from(tomlConfig)) }); t.truthy(profile.tls); From 7025aa1961262fdb8cf6c60bf0596f09f267696f Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 20 Oct 2025 19:37:30 -0700 Subject: [PATCH 3/7] PR suggestions, cleanup, test fix --- packages/envconfig/package.json | 6 +- packages/envconfig/src/envconfig-toml.ts | 177 ++++++++++++----------- packages/envconfig/src/types.ts | 47 ++---- packages/envconfig/src/utils.ts | 23 +-- packages/meta/package.json | 1 + packages/meta/src/index.ts | 1 + packages/test/src/test-envconfig.ts | 6 +- 7 files changed, 126 insertions(+), 135 deletions(-) diff --git a/packages/envconfig/package.json b/packages/envconfig/package.json index d39b6c585..741b34d6b 100644 --- a/packages/envconfig/package.json +++ b/packages/envconfig/package.json @@ -1,6 +1,6 @@ { "name": "@temporalio/envconfig", - "version": "1.0.0", + "version": "0.0.1", "description": "Temporal.io SDK Environment Configuration sub-package", "main": "lib/index.js", "types": "./lib/index.d.ts", @@ -15,9 +15,11 @@ "license": "MIT", "dependencies": { "@temporalio/common": "file:../common", - "@temporalio/worker": "file:../worker", "smol-toml": "1.4.2" }, + "devDependencies": { + "@temporalio/worker": "file:../worker" + }, "engines": { "node": ">= 18.0.0" }, diff --git a/packages/envconfig/src/envconfig-toml.ts b/packages/envconfig/src/envconfig-toml.ts index f0daf8435..891841f5a 100644 --- a/packages/envconfig/src/envconfig-toml.ts +++ b/packages/envconfig/src/envconfig-toml.ts @@ -2,23 +2,74 @@ import { readFileSync } from 'fs'; import * as path from 'path'; import * as os from 'os'; import { parse, stringify, TomlTable } from 'smol-toml'; -import { - ConfigDataSource, - LoadClientConfigOptions, - LoadClientProfileOptions, - TomlClientConfig, - TomlClientConfigCodec, - TomlClientConfigProfile, - TomlClientConfigTLS, -} from './types'; -import { normalizeGrpcMetaKey } from './utils'; +import { filterNullAndUndefined } from '@temporalio/common/lib/internal-workflow/objects-helpers'; +import { ConfigDataSource, LoadClientConfigOptions, LoadClientProfileOptions } from './types'; + +export function normalizeGrpcMetaKey(key: string): string { + return key.toLocaleLowerCase().replace('_', '-'); +} + +/** + * Raw TOML structure representing the client configuration file. + * + * @internal + * @experimental Environment configuration is new feature and subject to change. + */ +export interface TomlClientConfig { + profile: Record; +} + +/** + * Raw TOML structure for a client configuration profile. + * Note: field names use snake_case to match TOML file fields for correct parser deserialization. + * + * @internal + * @experimental Environment configuration is new feature and subject to change. + */ +export interface TomlClientConfigProfile { + address?: string; + namespace?: string; + api_key?: string; + tls?: TomlClientConfigTLS; + codec?: TomlClientConfigCodec; + grpc_meta?: Record; +} + +/** + * Raw TOML structure for client configuration TLS. + * + * @internal + * @experimental Environment configuration is new feature and subject to change. + */ +export interface TomlClientConfigTLS { + disabled?: boolean; + client_cert_path?: string; + client_cert_data?: string; + client_key_path?: string; + client_key_data?: string; + server_ca_cert_path?: string; + server_ca_cert_data?: string; + server_name?: string; + disable_host_verification?: boolean; +} + +/** + * Raw TOML structure for client configuration codec. + * + * @internal + * @experimental Environment configuration is new feature and subject to change. + */ +export interface TomlClientConfigCodec { + endpoint?: string; + auth?: string; +} function sourceToStringData(source?: ConfigDataSource): string | undefined { if (source === undefined) { return undefined; } if ('path' in source) { - return readFileSync(source.path)?.toString(); + return readFileSync(source.path, { encoding: 'utf-8' }); } if (Buffer.isBuffer(source.data)) { @@ -27,25 +78,14 @@ function sourceToStringData(source?: ConfigDataSource): string | undefined { return source.data; } -type EnvProvider = { kind: 'Map'; map: Record } | { kind: 'System' }; - -function getEnvVar(provider: EnvProvider, key: string): string | undefined { - if (provider.kind === 'Map') { - return provider.map[key]; - } - return process.env[key]; -} - export function tomlLoadClientConfig(options: LoadClientConfigOptions): TomlClientConfig { - const envProvider: EnvProvider = options.overrideEnvVars - ? { kind: 'Map', map: options.overrideEnvVars } - : { kind: 'System' }; + const envProvider: Record = options.overrideEnvVars ?? process.env; let configData = undefined; try { configData = sourceToStringData(options.configSource) ?? getFallbackConfigData(envProvider); } catch (error) { - const isFileNotFound = error instanceof Error && 'code' in error && error.code === 'ENOENT'; + const isFileNotFound = (error as NodeJS.ErrnoException)?.code === 'ENOENT'; if (!isFileNotFound) { throw error; } @@ -140,13 +180,13 @@ function strictValidateTomlStructure(parsed: TomlTable): void { } } -function getFallbackConfigData(envProvider: EnvProvider): string | undefined { +function getFallbackConfigData(envProvider: Record): string | undefined { // configSource was not set - fallback to TEMPORAL_CONFIG_FILE, then the default file path - let filePath = getEnvVar(envProvider, 'TEMPORAL_CONFIG_FILE'); + let filePath = envProvider['TEMPORAL_CONFIG_FILE']; if (filePath === undefined) { filePath = getDefaultConfigFilePath(); } - return readFileSync(filePath).toString(); + return readFileSync(filePath, { encoding: 'utf-8' }); } export function tomlLoadClientConfigProfile(options: LoadClientProfileOptions): TomlClientConfigProfile { @@ -154,9 +194,7 @@ export function tomlLoadClientConfigProfile(options: LoadClientProfileOptions): throw new Error('Cannot disable both file and environment loading'); } - const envProvider: EnvProvider = options.overrideEnvVars - ? { kind: 'Map', map: options.overrideEnvVars } - : { kind: 'System' }; + const envProvider: Record = options.overrideEnvVars ?? process.env; let profile: TomlClientConfigProfile = {}; @@ -167,7 +205,7 @@ export function tomlLoadClientConfigProfile(options: LoadClientProfileOptions): overrideEnvVars: options.overrideEnvVars, }); // If profile name not provided, fallback to env variable. - const profileName = options.profile ?? getEnvVar(envProvider, 'TEMPORAL_PROFILE'); + const profileName = options.profile ?? envProvider['TEMPORAL_PROFILE']; // If env var also not provided, fallback to default profile name. const tomlProfile = tomlClientConfig.profile[profileName ?? DEFAULT_CONFIG_FILE_PROFILE]; // If toml profile does not exist and an explicit profile was requested, error. @@ -184,10 +222,10 @@ export function tomlLoadClientConfigProfile(options: LoadClientProfileOptions): return profile; } -function applyProfileEnvVars(profile: TomlClientConfigProfile, envProvider: EnvProvider) { - profile.address = getEnvVar(envProvider, 'TEMPORAL_ADDRESS') ?? profile.address; - profile.namespace = getEnvVar(envProvider, 'TEMPORAL_NAMESPACE') ?? profile.namespace; - profile.api_key = getEnvVar(envProvider, 'TEMPORAL_API_KEY') ?? profile.api_key; +function applyProfileEnvVars(profile: TomlClientConfigProfile, envProvider: Record) { + profile.address = envProvider['TEMPORAL_ADDRESS'] ?? profile.address; + profile.namespace = envProvider['TEMPORAL_NAMESPACE'] ?? profile.namespace; + profile.api_key = envProvider['TEMPORAL_API_KEY'] ?? profile.api_key; const tlsFromEnv = getTLSFromEnvVars(envProvider); profile.tls = profile.tls || tlsFromEnv ? { ...profile.tls, ...tlsFromEnv } : undefined; const codecFromEnv = getCodecFromEnvVars(envProvider); @@ -195,55 +233,30 @@ function applyProfileEnvVars(profile: TomlClientConfigProfile, envProvider: EnvP applyGrpcMetaFromEnvVars(profile, envProvider); } -function getTLSFromEnvVars(envProvider: EnvProvider): TomlClientConfigTLS | undefined { - const tlsConfig: TomlClientConfigTLS = {}; - const tlsEnvVar = getEnvVar(envProvider, 'TEMPORAL_TLS'); - if (tlsEnvVar !== undefined) { - tlsConfig.disabled = envVarToBool(tlsEnvVar); - } - const clientCertPath = getEnvVar(envProvider, 'TEMPORAL_TLS_CLIENT_CERT_PATH'); - if (clientCertPath !== undefined) { - tlsConfig.client_cert_path = clientCertPath; - } - const clientCertData = getEnvVar(envProvider, 'TEMPORAL_TLS_CLIENT_CERT_DATA'); - if (clientCertData !== undefined) { - tlsConfig.client_cert_data = clientCertData; - } - const clientKeyPath = getEnvVar(envProvider, 'TEMPORAL_TLS_CLIENT_KEY_PATH'); - if (clientKeyPath !== undefined) { - tlsConfig.client_key_path = clientKeyPath; - } - const clientKeyData = getEnvVar(envProvider, 'TEMPORAL_TLS_CLIENT_KEY_DATA'); - if (clientKeyData !== undefined) { - tlsConfig.client_key_data = clientKeyData; - } - const serverCertPath = getEnvVar(envProvider, 'TEMPORAL_TLS_SERVER_CA_CERT_PATH'); - if (serverCertPath !== undefined) { - tlsConfig.server_ca_cert_path = serverCertPath; - } - const serverCertData = getEnvVar(envProvider, 'TEMPORAL_TLS_SERVER_CA_CERT_DATA'); - if (serverCertData !== undefined) { - tlsConfig.server_ca_cert_data = serverCertData; - } - const serverName = getEnvVar(envProvider, 'TEMPORAL_TLS_SERVER_NAME'); - if (serverName !== undefined) { - tlsConfig.server_name = serverName; - } - const disableHostVerification = getEnvVar(envProvider, 'TEMPORAL_TLS_DISABLE_HOST_VERIFICATION'); - if (disableHostVerification !== undefined) { - tlsConfig.disable_host_verification = envVarToBool(disableHostVerification); - } +function getTLSFromEnvVars(envProvider: Record): TomlClientConfigTLS | undefined { + const tlsConfig: TomlClientConfigTLS = filterNullAndUndefined({ + disabled: envVarToBool(envProvider['TEMPORAL_TLS']), + client_cert_path: envProvider['TEMPORAL_TLS_CLIENT_CERT_PATH'], + client_cert_data: envProvider['TEMPORAL_TLS_CLIENT_CERT_DATA'], + client_key_path: envProvider['TEMPORAL_TLS_CLIENT_KEY_PATH'], + client_key_data: envProvider['TEMPORAL_TLS_CLIENT_KEY_DATA'], + server_ca_cert_path: envProvider['TEMPORAL_TLS_SERVER_CA_CERT_PATH'], + server_ca_cert_data: envProvider['TEMPORAL_TLS_SERVER_CA_CERT_DATA'], + server_name: envProvider['TEMPORAL_TLS_SERVER_NAME'], + disable_host_verification: envVarToBool(envProvider['TEMPORAL_TLS_DISABLE_HOST_VERIFICATION']), + }); + // If no properties were added, return undefined return Object.keys(tlsConfig).length > 0 ? tlsConfig : undefined; } -function getCodecFromEnvVars(envProvider: EnvProvider): TomlClientConfigCodec | undefined { +function getCodecFromEnvVars(envProvider: Record): TomlClientConfigCodec | undefined { const codec: TomlClientConfigCodec = {}; - const endpoint = getEnvVar(envProvider, 'TEMPORAL_CODEC_ENDPOINT'); + const endpoint = envProvider['TEMPORAL_CODEC_ENDPOINT']; if (endpoint !== undefined) { codec.endpoint = endpoint; } - const auth = getEnvVar(envProvider, 'TEMPORAL_CODEC_AUTH'); + const auth = envProvider['TEMPORAL_CODEC_AUTH']; if (auth !== undefined) { codec.auth = auth; } @@ -251,13 +264,10 @@ function getCodecFromEnvVars(envProvider: EnvProvider): TomlClientConfigCodec | return Object.keys(codec).length > 0 ? codec : undefined; } -function applyGrpcMetaFromEnvVars(profile: TomlClientConfigProfile, envProvider: EnvProvider) { +function applyGrpcMetaFromEnvVars(profile: TomlClientConfigProfile, envProvider: Record) { const PREFIX = 'TEMPORAL_GRPC_META_'; - // Get key-value pairs based on provider type - const envVars = envProvider.kind === 'Map' ? Object.entries(envProvider.map) : Object.entries(process.env); - - for (const [key, value] of envVars) { + for (const [key, value] of Object.entries(envProvider)) { if (key.startsWith(PREFIX)) { const headerName = key.slice(PREFIX.length); const normalizedKey = normalizeGrpcMetaKey(headerName); @@ -275,7 +285,10 @@ function applyGrpcMetaFromEnvVars(profile: TomlClientConfigProfile, envProvider: } } -function envVarToBool(envVar: string): boolean { +function envVarToBool(envVar?: string): boolean | undefined { + if (envVar === undefined) { + return undefined; + } return envVar === '1' || envVar === 'true'; } diff --git a/packages/envconfig/src/types.ts b/packages/envconfig/src/types.ts index 1edd37696..330cc00b1 100644 --- a/packages/envconfig/src/types.ts +++ b/packages/envconfig/src/types.ts @@ -1,4 +1,4 @@ -import { NativeConnectionOptions } from '@temporalio/worker'; +import type { NativeConnectionOptions } from '@temporalio/worker'; /** * A data source for configuration, which can be a path to a file, @@ -19,6 +19,11 @@ export interface ClientConfigTLS { serverCACert?: ConfigDataSource; } +/** + * Configuration for connecting to a Temporal client, including connection options and namespace. + * + * @experimental Environment configuration is new feature and subject to change. + */ export interface ClientConnectConfig { connectionOptions: NativeConnectionOptions; namespace?: string; @@ -54,6 +59,11 @@ export interface LoadClientProfileOptions { overrideEnvVars?: Record; } +/** + * A client configuration profile with connection settings for a Temporal client. + * + * @experimental Environment configuration is new feature and subject to change. + */ export interface ClientConfigProfile { address?: string; namespace?: string; @@ -93,37 +103,12 @@ export interface ClientConfig { profiles: Record; } +/** + * Options for parsing client configuration from TOML format. + * + * @experimental Environment configuration is new feature and subject to change. + */ export interface ClientConfigFromTomlOptions { // If true, will error if there are unrecognized keys. strict: boolean; } - -export interface TomlClientConfig { - profile: Record; -} - -export interface TomlClientConfigProfile { - address?: string; - namespace?: string; - api_key?: string; - tls?: TomlClientConfigTLS; - codec?: TomlClientConfigCodec; - grpc_meta?: Record; -} - -export interface TomlClientConfigTLS { - disabled?: boolean; - client_cert_path?: string; - client_cert_data?: string; - client_key_path?: string; - client_key_data?: string; - server_ca_cert_path?: string; - server_ca_cert_data?: string; - server_name?: string; - disable_host_verification?: boolean; -} - -export interface TomlClientConfigCodec { - endpoint?: string; - auth?: string; -} diff --git a/packages/envconfig/src/utils.ts b/packages/envconfig/src/utils.ts index f3132c66f..f51aad22b 100644 --- a/packages/envconfig/src/utils.ts +++ b/packages/envconfig/src/utils.ts @@ -1,14 +1,7 @@ import { readFileSync } from 'fs'; import { filterNullAndUndefined } from '@temporalio/common/lib/internal-workflow'; -import { - ClientConfigProfile, - ClientConfigTLS, - ClientConfig, - TomlClientConfigProfile, - TomlClientConfigTLS, - TomlClientConfig, - ConfigDataSource, -} from './types'; +import { ClientConfigProfile, ClientConfigTLS, ClientConfig, ConfigDataSource } from './types'; +import { normalizeGrpcMetaKey, TomlClientConfig, TomlClientConfigProfile, TomlClientConfigTLS } from './envconfig-toml'; export function loadConfigData(source?: ConfigDataSource): Buffer | undefined { if (!source) return undefined; @@ -20,17 +13,13 @@ export function loadConfigData(source?: ConfigDataSource): Buffer | undefined { return Buffer.isBuffer(source.data) ? source.data : Buffer.from(source.data); } -export function normalizeGrpcMetaKey(key: string): string { - return key.toLocaleLowerCase().replace('_', '-'); -} - export function fromTomlProfile(tomlProfile: TomlClientConfigProfile): ClientConfigProfile { let grpcMeta: Record | undefined = undefined; if (tomlProfile.grpc_meta !== undefined) { grpcMeta = {}; // Normalize GRPC meta keys. - for (const key in tomlProfile.grpc_meta) { - grpcMeta[normalizeGrpcMetaKey(key)] = tomlProfile.grpc_meta[key]; + for (const [key, value] of Object.entries(tomlProfile.grpc_meta)) { + grpcMeta[normalizeGrpcMetaKey(key)] = value; } } const profile: ClientConfigProfile = { @@ -48,8 +37,8 @@ export function toTomlProfile(profile: ClientConfigProfile): TomlClientConfigPro if (profile.grpcMeta !== undefined) { grpc_meta = {}; // Normalize GRPC meta keys. - for (const key in profile.grpcMeta) { - grpc_meta[normalizeGrpcMetaKey(key)] = profile.grpcMeta[key]; + for (const [key, value] of Object.entries(profile.grpcMeta)) { + grpc_meta[normalizeGrpcMetaKey(key)] = value; } } const tomlProfile = { diff --git a/packages/meta/package.json b/packages/meta/package.json index c2e000a58..fc5c381c4 100644 --- a/packages/meta/package.json +++ b/packages/meta/package.json @@ -5,6 +5,7 @@ "@temporalio/activity": "file:../activity", "@temporalio/client": "file:../client", "@temporalio/common": "file:../common", + "@temporalio/envconfig": "file:../envconfig", "@temporalio/interceptors-opentelemetry": "file:../interceptors-opentelemetry", "@temporalio/nexus": "file:../nexus", "@temporalio/proto": "file:../proto", diff --git a/packages/meta/src/index.ts b/packages/meta/src/index.ts index c4ed0a6ef..45009335a 100644 --- a/packages/meta/src/index.ts +++ b/packages/meta/src/index.ts @@ -10,3 +10,4 @@ export * as client from '@temporalio/client'; export * as nexus from '@temporalio/nexus'; export * as testing from '@temporalio/testing'; export * as opentelemetry from '@temporalio/interceptors-opentelemetry'; +export * as envconfig from '@temporalio/envconfig'; diff --git a/packages/test/src/test-envconfig.ts b/packages/test/src/test-envconfig.ts index 33c9e703e..1a1fa888f 100644 --- a/packages/test/src/test-envconfig.ts +++ b/packages/test/src/test-envconfig.ts @@ -462,15 +462,15 @@ test('Load profile with TLS options as file paths', (t) => { const serverCACert = toPathAndData(profile.tls?.serverCACert); t.is(serverCACert?.data, undefined); - t.deepEqual(serverCACert?.path, caPath); + t.deepEqual(serverCACert?.path, normalizedCaPath); const clientCert = toPathAndData(profile.tls?.clientCert); t.is(clientCert?.data, undefined); - t.deepEqual(clientCert?.path, certPath); + t.deepEqual(clientCert?.path, normalizedCertPath); const clientKey = toPathAndData(profile.tls?.clientKey); t.is(clientKey?.data, undefined); - t.deepEqual(clientKey?.path, keyPath); + t.deepEqual(clientKey?.path, normalizedKeyPath); const { connectionOptions: connOpts } = toClientOptions(profile); const tls3 = connOpts.tls; From 3c82a5713eb3140d5f96f4b9e492cda94bcee75a Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 20 Oct 2025 20:35:57 -0700 Subject: [PATCH 4/7] add experimental tag --- packages/envconfig/src/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/envconfig/src/types.ts b/packages/envconfig/src/types.ts index 330cc00b1..8bdfbee1d 100644 --- a/packages/envconfig/src/types.ts +++ b/packages/envconfig/src/types.ts @@ -3,6 +3,8 @@ import type { NativeConnectionOptions } from '@temporalio/worker'; /** * A data source for configuration, which can be a path to a file, * the string contents of a file, or raw bytes. + * + * @experimental Environment configuration is new feature and subject to change. */ export type ConfigDataSource = { path: string } | { data: string | Buffer }; From 8a1530033e434f81544fb4466186993fdcb1cd2f Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Tue, 21 Oct 2025 07:53:25 -0700 Subject: [PATCH 5/7] formatting --- packages/envconfig/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/envconfig/src/types.ts b/packages/envconfig/src/types.ts index 8bdfbee1d..6803b8d21 100644 --- a/packages/envconfig/src/types.ts +++ b/packages/envconfig/src/types.ts @@ -3,7 +3,7 @@ import type { NativeConnectionOptions } from '@temporalio/worker'; /** * A data source for configuration, which can be a path to a file, * the string contents of a file, or raw bytes. - * + * * @experimental Environment configuration is new feature and subject to change. */ export type ConfigDataSource = { path: string } | { data: string | Buffer }; From fda0bd2991d0bc8d4cec4da71d933365a4a18c73 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 23 Oct 2025 15:41:08 -0700 Subject: [PATCH 6/7] Move from Buffer to Uint8Array. Add doc strings. Address final PR suggestions --- packages/client/src/connection.ts | 5 +- .../src/internal-non-workflow/tls-config.ts | 6 +- packages/envconfig/src/envconfig-toml.ts | 14 +-- packages/envconfig/src/envconfig.ts | 7 +- packages/envconfig/src/types.ts | 4 +- packages/envconfig/src/utils.ts | 60 +++++++++--- packages/test/src/test-envconfig.ts | 93 +++++++++---------- packages/worker/src/connection-options.ts | 6 +- 8 files changed, 117 insertions(+), 78 deletions(-) diff --git a/packages/client/src/connection.ts b/packages/client/src/connection.ts index 4e18e430b..96b14fca9 100644 --- a/packages/client/src/connection.ts +++ b/packages/client/src/connection.ts @@ -204,10 +204,13 @@ function normalizeGRPCConfig(options?: ConnectionOptions): ConnectionOptions { if (credentials) { throw new TypeError('Both `tls` and `credentials` ConnectionOptions were provided'); } + const serverRootCert = tls.serverRootCACertificate && Buffer.from(tls.serverRootCACertificate); + const clientCertKey = tls.clientCertPair?.key && Buffer.from(tls.clientCertPair?.key); + const clientCertCrt = tls.clientCertPair?.crt && Buffer.from(tls.clientCertPair?.crt); return { ...rest, credentials: grpc.credentials.combineChannelCredentials( - grpc.credentials.createSsl(tls.serverRootCACertificate, tls.clientCertPair?.key, tls.clientCertPair?.crt), + grpc.credentials.createSsl(serverRootCert, clientCertKey, clientCertCrt), ...(callCredentials ?? []) ), channelArgs: { diff --git a/packages/common/src/internal-non-workflow/tls-config.ts b/packages/common/src/internal-non-workflow/tls-config.ts index 1a88aba5e..d499af652 100644 --- a/packages/common/src/internal-non-workflow/tls-config.ts +++ b/packages/common/src/internal-non-workflow/tls-config.ts @@ -12,13 +12,13 @@ export interface TLSConfig { * Root CA certificate used by the server. If not set, and the server's * cert is issued by someone the operating system trusts, verification will still work (ex: Cloud offering). */ - serverRootCACertificate?: Buffer; + serverRootCACertificate?: Uint8Array; /** Sets the client certificate and key for connecting with mTLS */ clientCertPair?: { /** The certificate for this client */ - crt: Buffer; + crt: Uint8Array; /** The private key for this client */ - key: Buffer; + key: Uint8Array; }; } diff --git a/packages/envconfig/src/envconfig-toml.ts b/packages/envconfig/src/envconfig-toml.ts index 891841f5a..c96dd118a 100644 --- a/packages/envconfig/src/envconfig-toml.ts +++ b/packages/envconfig/src/envconfig-toml.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as os from 'os'; import { parse, stringify, TomlTable } from 'smol-toml'; import { filterNullAndUndefined } from '@temporalio/common/lib/internal-workflow/objects-helpers'; +import { decode, encode } from '@temporalio/common/lib/encoding'; import { ConfigDataSource, LoadClientConfigOptions, LoadClientProfileOptions } from './types'; export function normalizeGrpcMetaKey(key: string): string { @@ -64,7 +65,7 @@ export interface TomlClientConfigCodec { auth?: string; } -function sourceToStringData(source?: ConfigDataSource): string | undefined { +function sourceToStringData(source: ConfigDataSource | undefined): string | undefined { if (source === undefined) { return undefined; } @@ -72,10 +73,11 @@ function sourceToStringData(source?: ConfigDataSource): string | undefined { return readFileSync(source.path, { encoding: 'utf-8' }); } - if (Buffer.isBuffer(source.data)) { - return source.data.toString(); + if (typeof source.data === 'string') { + return source.data; } - return source.data; + + return decode(source.data); } export function tomlLoadClientConfig(options: LoadClientConfigOptions): TomlClientConfig { @@ -106,8 +108,8 @@ export function loadFromTomlData(tomlData: string, isStrict: boolean): TomlClien return parsed as unknown as TomlClientConfig; } -export function configToTomlData(config: TomlClientConfig): Buffer { - return Buffer.from(stringify(config), 'utf-8'); +export function configToTomlData(config: TomlClientConfig): Uint8Array { + return encode(stringify(config)); } function strictValidateTomlStructure(parsed: TomlTable): void { diff --git a/packages/envconfig/src/envconfig.ts b/packages/envconfig/src/envconfig.ts index 3ad88f704..57c6d547e 100644 --- a/packages/envconfig/src/envconfig.ts +++ b/packages/envconfig/src/envconfig.ts @@ -1,4 +1,5 @@ import type { TLSConfig } from '@temporalio/common/lib/internal-non-workflow'; +import { decode } from '@temporalio/common/lib/encoding'; import { configToTomlData, loadFromTomlData, @@ -20,11 +21,11 @@ export function loadClientConfig(options: LoadClientConfigOptions): ClientConfig return fromTomlConfig(tomlLoadClientConfig(options)); } -export function loadClientConfigFromToml(tomlData: Buffer, options: ClientConfigFromTomlOptions): ClientConfig { - return fromTomlConfig(loadFromTomlData(tomlData.toString(), options.strict)); +export function loadClientConfigFromToml(tomlData: Uint8Array, options: ClientConfigFromTomlOptions): ClientConfig { + return fromTomlConfig(loadFromTomlData(decode(tomlData), options.strict)); } -export function clientConfigToToml(config: ClientConfig): Buffer { +export function clientConfigToToml(config: ClientConfig): Uint8Array { return configToTomlData(toTomlConfig(config)); } diff --git a/packages/envconfig/src/types.ts b/packages/envconfig/src/types.ts index 6803b8d21..61f91e879 100644 --- a/packages/envconfig/src/types.ts +++ b/packages/envconfig/src/types.ts @@ -6,7 +6,7 @@ import type { NativeConnectionOptions } from '@temporalio/worker'; * * @experimental Environment configuration is new feature and subject to change. */ -export type ConfigDataSource = { path: string } | { data: string | Buffer }; +export type ConfigDataSource = { path: string } | { data: string | Uint8Array }; /** * TLS configuration as specified as part of client configuration. @@ -37,7 +37,7 @@ export interface ClientConnectConfig { * @experimental Environment configuration is new feature and subject to change. */ export interface LoadClientProfileOptions { - /** The profile to load from the config. Defaults to "default". */ + /** The name of the profile to load from the config. Defaults to "default". */ profile?: string; /** * If present, this is used as the configuration source instead of default diff --git a/packages/envconfig/src/utils.ts b/packages/envconfig/src/utils.ts index f51aad22b..ea5d4e63e 100644 --- a/packages/envconfig/src/utils.ts +++ b/packages/envconfig/src/utils.ts @@ -1,18 +1,29 @@ import { readFileSync } from 'fs'; import { filterNullAndUndefined } from '@temporalio/common/lib/internal-workflow'; +import { encode, decode } from '@temporalio/common/lib/encoding'; import { ClientConfigProfile, ClientConfigTLS, ClientConfig, ConfigDataSource } from './types'; import { normalizeGrpcMetaKey, TomlClientConfig, TomlClientConfigProfile, TomlClientConfigTLS } from './envconfig-toml'; -export function loadConfigData(source?: ConfigDataSource): Buffer | undefined { +/** + * Loads configuration data from a {@link ConfigDataSource} and returns it as a Uint8Array. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export function loadConfigData(source?: ConfigDataSource): Uint8Array | undefined { if (!source) return undefined; if ('path' in source) { - return readFileSync(source.path); + return Uint8Array.from(readFileSync(source.path)); } - return Buffer.isBuffer(source.data) ? source.data : Buffer.from(source.data); + return typeof source.data === 'string' ? encode(source.data) : source.data; } +/** + * Converts a TOML profile structure to a {@link ClientConfigProfile}. + * + * @experimental Environment configuration is new feature and subject to change. + */ export function fromTomlProfile(tomlProfile: TomlClientConfigProfile): ClientConfigProfile { let grpcMeta: Record | undefined = undefined; if (tomlProfile.grpc_meta !== undefined) { @@ -32,6 +43,11 @@ export function fromTomlProfile(tomlProfile: TomlClientConfigProfile): ClientCon return filterNullAndUndefined(profile); } +/** + * Converts a {@link ClientConfigProfile} to a TOML profile structure. + * + * @experimental Environment configuration is new feature and subject to change. + */ export function toTomlProfile(profile: ClientConfigProfile): TomlClientConfigProfile { let grpc_meta: Record | undefined = undefined; if (profile.grpcMeta !== undefined) { @@ -51,6 +67,11 @@ export function toTomlProfile(profile: ClientConfigProfile): TomlClientConfigPro return filterNullAndUndefined(tomlProfile); } +/** + * Converts a TOML TLS configuration structure to a {@link ClientConfigTLS}. + * + * @experimental Environment configuration is new feature and subject to change. + */ export function fromTomlTLS(tomlTLS?: TomlClientConfigTLS): ClientConfigTLS | undefined { if (tomlTLS === undefined) { return undefined; @@ -65,6 +86,11 @@ export function fromTomlTLS(tomlTLS?: TomlClientConfigTLS): ClientConfigTLS | un return filterNullAndUndefined(clientConfigTLS); } +/** + * Converts a {@link ClientConfigTLS} to a TOML TLS configuration structure. + * + * @experimental Environment configuration is new feature and subject to change. + */ export function toTomlTLS(tlsConfig?: ClientConfigTLS): TomlClientConfigTLS | undefined { if (tlsConfig === undefined) { return undefined; @@ -76,15 +102,20 @@ export function toTomlTLS(tlsConfig?: ClientConfigTLS): TomlClientConfigTLS | un disabled: tlsConfig.disabled, server_name: tlsConfig.serverName, client_cert_path: clientCert?.path, - client_cert_data: clientCert?.data?.toString() ?? undefined, + client_cert_data: clientCert?.data && decode(clientCert?.data), client_key_path: clientKey?.path, - client_key_data: clientKey?.data?.toString(), + client_key_data: clientKey?.data && decode(clientKey?.data), server_ca_cert_path: serverCACert?.path, - server_ca_cert_data: serverCACert?.data?.toString(), + server_ca_cert_data: serverCACert?.data && decode(serverCACert?.data), }; return filterNullAndUndefined(tomlConfigTLS); } +/** + * Converts a TOML client configuration structure to a {@link ClientConfig}. + * + * @experimental Environment configuration is new feature and subject to change. + */ export function fromTomlConfig(tomlConfig: TomlClientConfig): ClientConfig { const profiles: Record = {}; @@ -95,6 +126,11 @@ export function fromTomlConfig(tomlConfig: TomlClientConfig): ClientConfig { return { profiles }; } +/** + * Converts a {@link ClientConfig} to a TOML client configuration structure. + * + * @experimental Environment configuration is new feature and subject to change. + */ export function toTomlConfig(config: ClientConfig): TomlClientConfig { const profile: Record = {}; @@ -105,20 +141,20 @@ export function toTomlConfig(config: ClientConfig): TomlClientConfig { return { profile }; } -export function toPathAndData(source?: ConfigDataSource): { path?: string; data?: Buffer } | undefined { +export function toPathAndData(source?: ConfigDataSource): { path?: string; data?: Uint8Array } | undefined { if (source === undefined) { return undefined; } if ('path' in source) { return { path: source.path }; } - if (Buffer.isBuffer(source.data)) { - return { data: source.data }; + if (typeof source.data === 'string') { + return { data: encode(source.data) }; } - return { data: Buffer.from(source.data, 'utf8') }; + return { data: source.data }; } -export function toConfigDataSource( +function toConfigDataSource( path: string | undefined, data: string | undefined, fieldName: string @@ -127,7 +163,7 @@ export function toConfigDataSource( throw new Error(`Cannot specify both ${fieldName}_path and ${fieldName}_data`); } if (data !== undefined) { - return { data: Buffer.from(data) }; + return { data: encode(data) }; } if (path !== undefined) { return { path }; diff --git a/packages/test/src/test-envconfig.ts b/packages/test/src/test-envconfig.ts index 1a1fa888f..db18d1647 100644 --- a/packages/test/src/test-envconfig.ts +++ b/packages/test/src/test-envconfig.ts @@ -20,6 +20,7 @@ import { } from '@temporalio/envconfig'; import { toPathAndData } from '@temporalio/envconfig/lib/utils'; import { NativeConnection } from '@temporalio/worker'; +import { encode } from '@temporalio/common/lib/encoding'; // Focused TOML fixtures const TOML_CONFIG_BASE = dedent` @@ -73,8 +74,8 @@ function withTempFile(content: string, fn: (filepath: string) => void): void { function pathSource(p: string): ConfigDataSource { return { path: p }; } -function dataSource(d: Buffer | string): ConfigDataSource { - return { data: typeof d === 'string' ? Buffer.from(d) : d }; +function dataSource(d: Uint8Array | string): ConfigDataSource { + return { data: typeof d === 'string' ? encode(d) : d }; } // ============================================================================= @@ -124,7 +125,7 @@ test('Load custom profile from file', (t) => { }); test('Load default profile from data', (t) => { - const profile = loadClientConfigProfile({ configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)) }); + const profile = loadClientConfigProfile({ configSource: dataSource(TOML_CONFIG_BASE) }); t.is(profile.address, 'default-address'); t.is(profile.namespace, 'default-namespace'); t.is(profile.tls, undefined); @@ -133,7 +134,7 @@ test('Load default profile from data', (t) => { test('Load custom profile from data', (t) => { const profile = loadClientConfigProfile({ profile: 'custom', - configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)), + configSource: dataSource(TOML_CONFIG_BASE), }); t.is(profile.address, 'custom-address'); t.is(profile.namespace, 'custom-namespace'); @@ -147,7 +148,7 @@ test('Load profile from data with env overrides', (t) => { TEMPORAL_NAMESPACE: 'env-namespace', }; const profile = loadClientConfigProfile({ - configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)), + configSource: dataSource(TOML_CONFIG_BASE), overrideEnvVars: env, }); t.is(profile.address, 'env-address'); @@ -227,7 +228,7 @@ test('Load profile with grpc metadata env overrides', (t) => { TEMPORAL_GRPC_META_OVERRIDE_HEADER: 'overridden-value', }; const profile = loadClientConfigProfile({ - configSource: dataSource(Buffer.from(toml)), + configSource: dataSource(toml), overrideEnvVars: env, }); t.is(profile.grpcMeta?.['original-header'], 'original-value'); @@ -242,7 +243,7 @@ test('gRPC metadata normalization from TOML', (t) => { [profile.foo.grpc_meta] sOme-hEader_key = "some-value" `; - const conf = loadClientConfig({ configSource: dataSource(Buffer.from(toml)) }); + const conf = loadClientConfig({ configSource: dataSource(toml) }); const prof = conf.profiles['foo']; t.truthy(prof); t.is(prof.grpcMeta?.['some-header-key'], 'some-value'); @@ -260,7 +261,7 @@ test('gRPC metadata deletion via empty env value', (t) => { TEMPORAL_GRPC_META_REMOVE_ME: '', TEMPORAL_GRPC_META_NEW_HEADER: 'added', }; - const prof = loadClientConfigProfile({ configSource: dataSource(Buffer.from(toml)), overrideEnvVars: env }); + const prof = loadClientConfigProfile({ configSource: dataSource(toml), overrideEnvVars: env }); t.is(prof.grpcMeta?.['some-header'], 'keep'); t.is(prof.grpcMeta?.['new-header'], 'added'); t.false(Object.prototype.hasOwnProperty.call(prof.grpcMeta, 'remove-me')); @@ -330,7 +331,7 @@ test('Cannot disable both file and env override flags', (t) => { // ============================================================================= test('Load all profiles from file', (t) => { - const conf = loadClientConfig({ configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)) }); + const conf = loadClientConfig({ configSource: dataSource(TOML_CONFIG_BASE) }); t.truthy(conf.profiles['default']); t.truthy(conf.profiles['custom']); t.is(conf.profiles['default'].address, 'default-address'); @@ -347,7 +348,7 @@ test('Load all profiles from data', (t) => { address = "beta-address" api_key = "beta-key" `; - const conf = loadClientConfig({ configSource: dataSource(Buffer.from(configData)) }); + const conf = loadClientConfig({ configSource: dataSource(configData) }); t.truthy(conf.profiles['alpha']); t.truthy(conf.profiles['beta']); t.is(conf.profiles['alpha'].address, 'alpha-address'); @@ -374,7 +375,7 @@ test('Default profile not found returns empty profile', (t) => { [profile.existing] address = "my-address" `; - const prof = loadClientConfigProfile({ configSource: dataSource(Buffer.from(toml)) }); + const prof = loadClientConfigProfile({ configSource: dataSource(toml) }); t.is(prof.address, undefined); t.is(prof.namespace, undefined); t.is(prof.apiKey, undefined); @@ -393,7 +394,7 @@ test('Load profile with api key (enables TLS)', (t) => { address = "my-address" api_key = "my-api-key" `; - const profile = loadClientConfigProfile({ configSource: dataSource(Buffer.from(toml)) }); + const profile = loadClientConfigProfile({ configSource: dataSource(toml) }); t.is(profile.tls, undefined); t.is(profile.tls?.disabled, undefined); const { connectionOptions } = toClientOptions(profile); @@ -401,7 +402,7 @@ test('Load profile with api key (enables TLS)', (t) => { }); test('Load profile with TLS options', (t) => { - const configSource = dataSource(Buffer.from(TOML_CONFIG_TLS_DETAILED)); + const configSource = dataSource(TOML_CONFIG_TLS_DETAILED); const profileDisabled = loadClientConfigProfile({ configSource, profile: 'tls_disabled' }); t.truthy(profileDisabled.tls); @@ -415,24 +416,24 @@ test('Load profile with TLS options', (t) => { t.is(profileCerts.tls?.serverName, 'custom-server'); const serverCACert = toPathAndData(profileCerts.tls?.serverCACert); - t.deepEqual(serverCACert?.data, Buffer.from('ca-pem-data')); + t.deepEqual(serverCACert?.data, encode('ca-pem-data')); t.is(serverCACert?.path, undefined); const clientCert = toPathAndData(profileCerts.tls?.clientCert); - t.deepEqual(clientCert?.data, Buffer.from('client-crt-data')); + t.deepEqual(clientCert?.data, encode('client-crt-data')); t.is(clientCert?.path, undefined); const clientKey = toPathAndData(profileCerts.tls?.clientKey); - t.deepEqual(clientKey?.data, Buffer.from('client-key-data')); + t.deepEqual(clientKey?.data, encode('client-key-data')); t.is(clientKey?.path, undefined); const { connectionOptions: connOptsCerts } = toClientOptions(profileCerts); const tls2 = connOptsCerts.tls; if (tls2 && typeof tls2 === 'object') { t.is(tls2.serverNameOverride, 'custom-server'); - t.deepEqual(tls2.serverRootCACertificate, Buffer.from('ca-pem-data')); - t.deepEqual(tls2.clientCertPair?.crt, Buffer.from('client-crt-data')); - t.deepEqual(tls2.clientCertPair?.key, Buffer.from('client-key-data')); + t.deepEqual(tls2.serverRootCACertificate, encode('ca-pem-data')); + t.deepEqual(tls2.clientCertPair?.crt, encode('client-crt-data')); + t.deepEqual(tls2.clientCertPair?.key, encode('client-key-data')); } else { t.fail('expected TLS config object'); } @@ -456,7 +457,7 @@ test('Load profile with TLS options as file paths', (t) => { client_cert_path = "${normalizedCertPath}" client_key_path = "${normalizedKeyPath}" `; - const profile = loadClientConfigProfile({ configSource: dataSource(Buffer.from(tomlConfig)) }); + const profile = loadClientConfigProfile({ configSource: dataSource(tomlConfig) }); t.truthy(profile.tls); t.is(profile.tls?.serverName, 'custom-server'); @@ -476,9 +477,9 @@ test('Load profile with TLS options as file paths', (t) => { const tls3 = connOpts.tls; if (tls3 && typeof tls3 === 'object') { t.is(tls3.serverNameOverride, 'custom-server'); - t.deepEqual(tls3.serverRootCACertificate, Buffer.from('ca-pem-data')); - t.deepEqual(tls3.clientCertPair?.crt, Buffer.from('client-crt-data')); - t.deepEqual(tls3.clientCertPair?.key, Buffer.from('client-key-data')); + t.deepEqual(tls3.serverRootCACertificate, encode('ca-pem-data')); + t.deepEqual(tls3.clientCertPair?.crt, encode('client-crt-data')); + t.deepEqual(tls3.clientCertPair?.key, encode('client-key-data')); } else { t.fail('expected TLS config object'); } @@ -495,7 +496,7 @@ test('Load profile with conflicting cert source fails', (t) => { client_cert_path = "some-path" client_cert_data = "some-data" `; - const err = t.throws(() => loadClientConfigProfile({ configSource: dataSource(Buffer.from(toml)) })); + const err = t.throws(() => loadClientConfigProfile({ configSource: dataSource(toml) })); t.truthy(err); t.true(String(err?.message).includes('Cannot specify both')); }); @@ -508,9 +509,7 @@ test('TLS conflict across sources: path in TOML, data in env should error', (t) client_cert_path = "some-path" `; const env = { TEMPORAL_TLS_CLIENT_CERT_DATA: 'some-data' }; - const err = t.throws(() => - loadClientConfigProfile({ configSource: dataSource(Buffer.from(toml)), overrideEnvVars: env }) - ); + const err = t.throws(() => loadClientConfigProfile({ configSource: dataSource(toml), overrideEnvVars: env })); t.truthy(err); t.true( String(err?.message) @@ -527,9 +526,7 @@ test('TLS conflict across sources: data in TOML, path in env should error', (t) client_cert_data = "some-data" `; const env = { TEMPORAL_TLS_CLIENT_CERT_PATH: 'some-path' }; - const err = t.throws(() => - loadClientConfigProfile({ configSource: dataSource(Buffer.from(toml)), overrideEnvVars: env }) - ); + const err = t.throws(() => loadClientConfigProfile({ configSource: dataSource(toml), overrideEnvVars: env })); t.truthy(err); t.true( String(err?.message) @@ -547,7 +544,7 @@ test('TLS disabled tri-state behavior', (t) => { [profile.default.tls] server_name = "my-server" `; - const profileNull = loadClientConfigProfile({ configSource: dataSource(Buffer.from(tomlNull)) }); + const profileNull = loadClientConfigProfile({ configSource: dataSource(tomlNull) }); t.truthy(profileNull.tls); t.is(profileNull.tls?.disabled, undefined); // disabled is null (unset) const configNull = toClientOptions(profileNull); @@ -561,7 +558,7 @@ test('TLS disabled tri-state behavior', (t) => { disabled = false server_name = "my-server" `; - const profileFalse = loadClientConfigProfile({ configSource: dataSource(Buffer.from(tomlFalse)) }); + const profileFalse = loadClientConfigProfile({ configSource: dataSource(tomlFalse) }); t.truthy(profileFalse.tls); t.is(profileFalse.tls?.disabled, false); // explicitly disabled=false const configFalse = toClientOptions(profileFalse); @@ -576,7 +573,7 @@ test('TLS disabled tri-state behavior', (t) => { disabled = true server_name = "should-be-ignored" `; - const profileTrue = loadClientConfigProfile({ configSource: dataSource(Buffer.from(tomlTrue)) }); + const profileTrue = loadClientConfigProfile({ configSource: dataSource(tomlTrue) }); t.truthy(profileTrue.tls); t.is(profileTrue.tls?.disabled, true); // explicitly disabled=true const configTrue = toClientOptions(profileTrue); @@ -600,7 +597,7 @@ test('Load invalid config with strict mode enabled', (t) => { [unrecognized_table] foo = "bar" `; - const err = t.throws(() => loadClientConfig({ configSource: dataSource(Buffer.from(toml)), configFileStrict: true })); + const err = t.throws(() => loadClientConfig({ configSource: dataSource(toml), configFileStrict: true })); t.truthy(err); t.true(String(err?.message).includes('unrecognized_table')); }); @@ -614,7 +611,7 @@ test('Load invalid profile with strict mode enabled', (t) => { }); test('Load profiles with malformed TOML', (t) => { - const err = t.throws(() => loadClientConfig({ configSource: dataSource(Buffer.from('this is not valid toml')) })); + const err = t.throws(() => loadClientConfig({ configSource: dataSource('this is not valid toml') })); t.truthy(err); t.true( String(err?.message) @@ -634,9 +631,9 @@ test('Client config profile to/from TOML round-trip', (t) => { apiKey: 'some-api-key', tls: { serverName: 'some-server', - serverCACert: { data: Buffer.from('ca') }, + serverCACert: { data: encode('ca') }, clientCert: { path: '/path/to/client.crt' }, - clientKey: { data: Buffer.from('key') }, + clientKey: { data: encode('key') }, }, grpcMeta: { 'some-header': 'some-value' }, }; @@ -649,7 +646,7 @@ test('Client config profile to/from TOML round-trip', (t) => { t.is(back.tls?.serverName, 'some-server'); const serverCACert = toPathAndData(back.tls?.serverCACert); - t.deepEqual(serverCACert?.data, Buffer.from('ca')); + t.deepEqual(serverCACert?.data, encode('ca')); t.is(serverCACert?.path, undefined); const clientCert = toPathAndData(back.tls?.clientCert); @@ -657,7 +654,7 @@ test('Client config profile to/from TOML round-trip', (t) => { t.deepEqual(clientCert?.path, '/path/to/client.crt'); const clientKey = toPathAndData(back.tls?.clientKey); - t.deepEqual(clientKey?.data, Buffer.from('key')); + t.deepEqual(clientKey?.data, encode('key')); t.is(clientKey?.path, undefined); t.is(back.grpcMeta?.['some-header'], 'some-value'); @@ -728,7 +725,7 @@ test('Create client from default profile', async (t) => { // Load config via envconfig const { connectionOptions, namespace } = loadClientConnectConfig({ - configSource: dataSource(Buffer.from(toml)), + configSource: dataSource(toml), }); // Verify loaded values @@ -769,7 +766,7 @@ test('Create client with NativeConnection from default profile', async (t) => { // Load config via envconfig const { connectionOptions, namespace } = loadClientConnectConfig({ - configSource: dataSource(Buffer.from(toml)), + configSource: dataSource(toml), }); // Verify loaded values @@ -810,7 +807,7 @@ test('Create client from custom profile', async (t) => { // Load profile and create connection const profile = loadClientConfigProfile({ profile: 'development', - configSource: dataSource(Buffer.from(toml)), + configSource: dataSource(toml), }); t.is(profile.address, address); @@ -852,7 +849,7 @@ test('Create client from custom profile with TLS options', async (t) => { // Load profile and verify TLS/API key handling const profile = loadClientConfigProfile({ profile: 'production', - configSource: dataSource(Buffer.from(toml)), + configSource: dataSource(toml), }); t.is(profile.address, address); @@ -901,7 +898,7 @@ test('Create client from default profile with env overrides', async (t) => { // Load profile with environment overrides const profile = loadClientConfigProfile({ - configSource: dataSource(Buffer.from(toml)), + configSource: dataSource(toml), overrideEnvVars: envOverrides, }); @@ -950,12 +947,12 @@ test('Create clients from multi-profile config', async (t) => { // Load different profiles and create separate clients const profileA = loadClientConfigProfile({ profile: 'service-a', - configSource: dataSource(Buffer.from(toml)), + configSource: dataSource(toml), }); const profileB = loadClientConfigProfile({ profile: 'service-b', - configSource: dataSource(Buffer.from(toml)), + configSource: dataSource(toml), }); // Verify profiles are distinct @@ -1019,7 +1016,7 @@ test('Comprehensive E2E validation test', (t) => { const { connectionOptions, namespace } = loadClientConnectConfig({ profile: 'production', - configSource: dataSource(Buffer.from(tomlContent)), + configSource: dataSource(tomlContent), overrideEnvVars: envOverrides, }); @@ -1033,7 +1030,7 @@ test('Comprehensive E2E validation test', (t) => { const tls = connectionOptions.tls; if (tls && typeof tls === 'object') { t.is(tls.serverNameOverride, 'override.temporal.com'); // Env override - t.deepEqual(tls.serverRootCACertificate, Buffer.from('prod-ca-cert')); + t.deepEqual(tls.serverRootCACertificate, encode('prod-ca-cert')); } else { t.fail('expected TLS config object'); } diff --git a/packages/worker/src/connection-options.ts b/packages/worker/src/connection-options.ts index 41bdb80b4..92c4c071c 100644 --- a/packages/worker/src/connection-options.ts +++ b/packages/worker/src/connection-options.ts @@ -71,11 +71,11 @@ export function toNativeClientOptions(options: NativeConnectionOptions): native. const tls: native.TLSConfig | null = tlsInput ? { domain: tlsInput.serverNameOverride ?? null, - serverRootCaCert: tlsInput.serverRootCACertificate ?? null, + serverRootCaCert: tlsInput.serverRootCACertificate ? Buffer.from(tlsInput.serverRootCACertificate) : null, clientTlsConfig: tlsInput.clientCertPair ? { - clientCert: tlsInput.clientCertPair.crt, - clientPrivateKey: tlsInput.clientCertPair.key, + clientCert: Buffer.from(tlsInput.clientCertPair.crt), + clientPrivateKey: Buffer.from(tlsInput.clientCertPair.key), } : null, } From efb9668e58dcc981adc4467a965a846b5f62bf0f Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 23 Oct 2025 18:59:45 -0700 Subject: [PATCH 7/7] Small fix for test --- packages/worker/src/connection-options.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/worker/src/connection-options.ts b/packages/worker/src/connection-options.ts index 92c4c071c..0b81dd12a 100644 --- a/packages/worker/src/connection-options.ts +++ b/packages/worker/src/connection-options.ts @@ -74,8 +74,8 @@ export function toNativeClientOptions(options: NativeConnectionOptions): native. serverRootCaCert: tlsInput.serverRootCACertificate ? Buffer.from(tlsInput.serverRootCACertificate) : null, clientTlsConfig: tlsInput.clientCertPair ? { - clientCert: Buffer.from(tlsInput.clientCertPair.crt), - clientPrivateKey: Buffer.from(tlsInput.clientCertPair.key), + clientCert: tlsInput.clientCertPair.crt && Buffer.from(tlsInput.clientCertPair.crt), + clientPrivateKey: tlsInput.clientCertPair.key && Buffer.from(tlsInput.clientCertPair.key), } : null, }