From 9bc2b4fe19725afde63724bdcf7eac4798fc3aec Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Tue, 29 Jul 2025 11:11:44 -0400 Subject: [PATCH 1/9] envconfig impl --- packages/client/src/envconfig.ts | 437 +++++++++++++ packages/core-bridge/Cargo.lock | 167 +++++ packages/core-bridge/Cargo.toml | 1 + packages/core-bridge/sdk-core | 2 +- packages/core-bridge/src/envconfig.rs | 201 ++++++ .../core-bridge/src/helpers/try_into_js.rs | 17 +- packages/core-bridge/src/lib.rs | 2 + packages/core-bridge/ts/native.ts | 72 +++ packages/test/src/test-envconfig.ts | 583 ++++++++++++++++++ 9 files changed, 1480 insertions(+), 2 deletions(-) create mode 100644 packages/client/src/envconfig.ts create mode 100644 packages/core-bridge/src/envconfig.rs create mode 100644 packages/test/src/test-envconfig.ts diff --git a/packages/client/src/envconfig.ts b/packages/client/src/envconfig.ts new file mode 100644 index 000000000..f1d2ad9c8 --- /dev/null +++ b/packages/client/src/envconfig.ts @@ -0,0 +1,437 @@ +import * as fs from 'fs'; +import { native } from '@temporalio/core-bridge'; +import type { TLSConfig } from '@temporalio/common/lib/internal-non-workflow'; +import type { ConnectionOptions } from './connection'; + +/** + * A data source for configuration, which can be a path to a file, + * the string contents of a file, or raw bytes. + * + * It is defined as a tagged union, to be consumed by other parts of the SDK. + */ + +export type DataSource = { path: string } | { data: string | Buffer }; + +interface ClientConfigTLSJSON { + disabled?: boolean; + serverName?: string; + // TODO(assess): name Ca -> CA ? + serverCaCert?: Record; + clientCert?: Record; + clientKey?: Record; +} + +interface ClientConfigProfileJSON { + address?: string; + namespace?: string; + apiKey?: string; + tls?: ClientConfigTLSJSON; + grpcMeta?: Record; +} + +function recordToSource(d?: Record): DataSource | undefined { + if (d === undefined) { + return undefined; + } + if (d.data !== undefined) { + return { data: d.data }; + } + if (d.path !== undefined && typeof d.path === 'string') { + return { path: d.path }; + } + return undefined; +} + +function sourceToRecord(source?: DataSource): Record | undefined { + if (source === undefined) { + return undefined; + } + + if ('path' in source) { + return { path: source.path }; + } else { + // source.data + if (Buffer.isBuffer(source.data)) { + return { data: source.data.toString('utf8') }; + } else { + return { data: source.data }; + } + } +} + +function sourceToPathAndData(source?: DataSource): { path?: string; data?: Buffer } { + if (!source) { + return {}; + } + if ('path' in source) { + return { path: source.path }; + } + if (Buffer.isBuffer(source.data)) { + return { data: source.data }; + } + return { data: Buffer.from(source.data, 'utf8') }; +} + +/** + * Synchronously takes a DataSource and resolves it into its raw Buffer representation. + * - If it's a path, it reads the file from disk, blocking the event loop. + * - If it's content, it ensures it's a Buffer. + */ +function readSourceSync(source?: DataSource): Buffer | undefined { + if (source === undefined) { + return undefined; + } + + if ('path' in source) { + // This is correct. It reads the whole file into a Buffer asynchronously. + return fs.readFileSync(source.path); + } + + if (Buffer.isBuffer(source.data)) { + return source.data; + } + + // If it's not a buffer, it must be a string. + return Buffer.from(source.data, 'utf8'); +} + +function bridgeToDataSource(bridgeSource?: native.DataSource | null): DataSource | undefined { + if (bridgeSource == null) { + return undefined; + } + if (bridgeSource.path != null) { + return { path: bridgeSource.path }; + } + if (bridgeSource.data != null) { + return { data: bridgeSource.data }; + } + throw new TypeError('Invalid DataSource object received from bridge'); +} + +function clientConfigTlsFromBridge(tls: native.ClientConfigProfile['tls']): ClientConfigTLS { + return new ClientConfigTLS({ + disabled: tls?.disabled, + serverName: tls?.serverName ?? undefined, + serverRootCaCert: bridgeToDataSource(tls?.serverCaCert), + clientCert: bridgeToDataSource(tls?.clientCert), + clientPrivateKey: bridgeToDataSource(tls?.clientKey), + }); +} + +function clientConfigProfileFromBridge(profile: native.ClientConfigProfile): ClientConfigProfile { + return new ClientConfigProfile({ + address: profile.address ?? undefined, + namespace: profile.namespace ?? undefined, + apiKey: profile.apiKey ?? undefined, + tls: profile.tls ? clientConfigTlsFromBridge(profile.tls) : undefined, + grpcMeta: profile.grpcMeta, + }); +} + +function clientConfigFromBridge(bridgeConfig: native.ClientConfig): ClientConfig { + const profiles: Record = {}; + for (const [name, profile] of Object.entries(bridgeConfig.profiles)) { + profiles[name] = clientConfigProfileFromBridge(profile); + } + return new ClientConfig(profiles); +} + +/** + * Options for `ClientConfigTLS` constructor. + */ +export interface ClientConfigTLSOptions { + disabled?: boolean; + serverName?: string; + serverRootCaCert?: DataSource; + clientCert?: DataSource; + clientPrivateKey?: DataSource; +} + +/** + * TLS configuration as specified as part of client configuration. + * + * This class is the TypeScript equivalent of the Python SDK's `ClientConfigTLS`. + * @experimental + */ +export class ClientConfigTLS { + public readonly disabled: boolean; + public readonly serverName?: string; + public readonly serverRootCaCert?: DataSource; + public readonly clientCert?: DataSource; + public readonly clientPrivateKey?: DataSource; + + constructor(options: ClientConfigTLSOptions) { + this.disabled = options.disabled ?? false; + this.serverName = options.serverName; + this.serverRootCaCert = options.serverRootCaCert; + this.clientCert = options.clientCert; + this.clientPrivateKey = options.clientPrivateKey; + } + + public toJSON(): ClientConfigTLSJSON { + const json: ClientConfigTLSJSON = {}; + if (this.disabled) { + json.disabled = this.disabled; + } + if (this.serverName) { + json.serverName = this.serverName; + } + if (this.serverRootCaCert) { + json.serverCaCert = sourceToRecord(this.serverRootCaCert); + } + if (this.clientCert) { + json.clientCert = sourceToRecord(this.clientCert); + } + if (this.clientPrivateKey) { + json.clientKey = sourceToRecord(this.clientPrivateKey); + } + return json; + } + + public static fromJSON(json: ClientConfigTLSJSON): ClientConfigTLS { + return new ClientConfigTLS({ + disabled: json.disabled, + serverName: json.serverName, + serverRootCaCert: recordToSource(json.serverCaCert), + clientCert: recordToSource(json.clientCert), + clientPrivateKey: recordToSource(json.clientKey), + }); + } + + // TODO(assess): add doc string + public toTLSConfig(): TLSConfig | undefined { + if (this.disabled) { + return undefined; + } + + const crtBuffer = readSourceSync(this.clientCert); + const keyBuffer = readSourceSync(this.clientPrivateKey); + + const tlsConfig: TLSConfig = { + serverNameOverride: this.serverName, + serverRootCACertificate: readSourceSync(this.serverRootCaCert), + clientCertPair: + crtBuffer && keyBuffer + ? { + crt: crtBuffer, + key: keyBuffer, + } + : undefined, + }; + return tlsConfig; + } +} + +// TODO(assess): determine need for "ClientConnectConfig" +interface ClientConnectConfig { + connectionOptions: ConnectionOptions; + namespace?: string; +} + +export interface ClientConfigProfileOptions { + address?: string; + namespace?: string; + apiKey?: string; + tls?: ClientConfigTLS; + grpcMeta?: Record; +} + +/** + * Options for loading a client configuration profile. + * + * This is the TypeScript equivalent of the options passed to the Python SDK's + * `ClientConfigProfile.load` method. + * + * @experimental + */ +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?: DataSource; + /** + * 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; +} + +// TODO(assess): docstring +export class ClientConfigProfile { + public readonly address?: string; + public readonly namespace?: string; + public readonly apiKey?: string; + public readonly tls?: ClientConfigTLS; + public readonly grpcMeta: Record; + + public constructor(options: ClientConfigProfileOptions) { + this.address = options.address; + this.namespace = options.namespace; + this.apiKey = options.apiKey; + this.tls = options.tls; + this.grpcMeta = options.grpcMeta ?? {}; + } + + /** + * Loads a single client profile from environment variables and/or a TOML file. + */ + public static load(options: LoadClientProfileOptions = {}): ClientConfigProfile { + const { path, data } = sourceToPathAndData(options.configSource); + const bridgeProfile = native.loadClientConnectConfig( + options.profile ?? null, + path ?? null, + data ?? null, + options.disableFile ?? false, + options.disableEnv ?? false, + options.configFileStrict ?? false, + options.overrideEnvVars ?? null + ); + return clientConfigProfileFromBridge(bridgeProfile); + } + + public toJSON(): ClientConfigProfileJSON { + return { + address: this.address, + namespace: this.namespace, + apiKey: this.apiKey, + tls: this.tls?.toJSON(), + grpcMeta: this.grpcMeta, + }; + } + + public static fromJSON(json: ClientConfigProfileJSON): ClientConfigProfile { + return new ClientConfigProfile({ + address: json.address, + namespace: json.namespace, + apiKey: json.apiKey, + tls: json.tls ? ClientConfigTLS.fromJSON(json.tls) : undefined, + grpcMeta: json.grpcMeta, + }); + } + + // TODO(assess): docstring + public toClientConnectConfig(): ClientConnectConfig { + if (!this.address) { + throw new Error("Configuration profile must contain an 'address' to be used for client connection"); + } + + const tlsConfig = this.tls?.toTLSConfig(); + + return { + namespace: this.namespace, + connectionOptions: { + address: this.address, + apiKey: this.apiKey, + tls: tlsConfig, + metadata: this.grpcMeta, + }, + }; + } +} + +/** + * Options for loading client configuration. + * @experimental + */ +export interface LoadClientConfigOptions { + /** + * 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?: DataSource; + /** + * If true, file loading is disabled. This is only used when `configSource` + * is not present. + */ + disableFile?: boolean; + /** 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; +} + +interface ClientConfigJSON { + profiles: Record; +} + +// TODO(assess): update docstrings +/** + * Client configuration loaded from TOML and environment variables. + * + * This class is the TypeScript equivalent of the Python SDK's `ClientConfig`. + * It contains a mapping of profile names to client profiles. + * + * @experimental + */ +export class ClientConfig { + /** Map of profile name to its corresponding ClientConfigProfile. */ + public readonly profiles: Record; + + /** + * Load all client profiles from given sources. + * + * This does not apply environment variable overrides to the profiles, it + * only uses an environment variable to find the default config file path + * (`TEMPORAL_CONFIG_FILE`). To get a single profile with environment variables + * applied, use {@link ClientConfigProfile.load}. + */ + public static load(options: LoadClientConfigOptions = {}): ClientConfig { + const { path, data } = sourceToPathAndData(options.configSource); + const bridgeConfig = native.loadClientConfig( + path ?? null, + data ?? null, + options.disableFile ?? false, + options.configFileStrict ?? false, + options.overrideEnvVars ?? null + ); + return clientConfigFromBridge(bridgeConfig); + } + + /** + * A convenience function that loads a single client profile and converts it to connect options. + * + * @param options Options for loading the client profile. + * @returns A {@link ConnectionOptions} object that can be used to connect a client. + */ + public static loadClientConnectConfig(options: LoadClientProfileOptions = {}): ClientConnectConfig { + return ClientConfigProfile.load(options).toClientConnectConfig(); + } + + public constructor(profiles: Record) { + this.profiles = profiles; + } + + public toJSON(): ClientConfigJSON { + const profiles: Record = {}; + for (const [name, profile] of Object.entries(this.profiles)) { + profiles[name] = profile.toJSON(); + } + return { profiles }; + } + + public static fromJSON(json: ClientConfigJSON): ClientConfig { + const profiles: Record = {}; + for (const [name, profile] of Object.entries(json.profiles)) { + profiles[name] = ClientConfigProfile.fromJSON(profile); + } + return new ClientConfig(profiles); + } +} diff --git a/packages/core-bridge/Cargo.lock b/packages/core-bridge/Cargo.lock index d887f2eff..79078de1e 100644 --- a/packages/core-bridge/Cargo.lock +++ b/packages/core-bridge/Cargo.lock @@ -505,6 +505,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1596,6 +1617,12 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "os_pipe" version = "1.2.2" @@ -2087,6 +2114,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.11.1" @@ -2388,6 +2426,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2679,11 +2726,14 @@ dependencies = [ "async-trait", "derive_builder", "derive_more", + "dirs", "opentelemetry 0.30.0", "prost", + "serde", "serde_json", "temporal-sdk-core-protos", "thiserror 2.0.15", + "toml", "tonic", "tracing", "tracing-core", @@ -2729,6 +2779,7 @@ dependencies = [ "serde_json", "temporal-client", "temporal-sdk-core", + "temporal-sdk-core-api", "thiserror 2.0.15", "tokio", "tokio-stream", @@ -2901,6 +2952,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tonic" version = "0.13.1" @@ -3359,6 +3451,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3386,6 +3487,21 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -3419,6 +3535,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3431,6 +3553,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3443,6 +3571,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3467,6 +3601,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3479,6 +3619,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3491,6 +3637,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3503,6 +3655,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3515,6 +3673,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/packages/core-bridge/Cargo.toml b/packages/core-bridge/Cargo.toml index 1831c7b59..723b6f53f 100644 --- a/packages/core-bridge/Cargo.toml +++ b/packages/core-bridge/Cargo.toml @@ -38,6 +38,7 @@ serde_json = "1.0" temporal-sdk-core = { version = "*", path = "./sdk-core/core", features = [ "ephemeral-server", ] } +temporal-sdk-core-api = { path = "./sdk-core/core-api", features = ["envconfig"] } temporal-client = { version = "*", path = "./sdk-core/client" } thiserror = "2" tokio = "1.13" diff --git a/packages/core-bridge/sdk-core b/packages/core-bridge/sdk-core index 871b320c8..8dd4a20ab 160000 --- a/packages/core-bridge/sdk-core +++ b/packages/core-bridge/sdk-core @@ -1 +1 @@ -Subproject commit 871b320c8f51d52cb69fcc31f9c4dcd47b9f3961 +Subproject commit 8dd4a20ab06e8dee2208da15a96d60efb1bcf043 diff --git a/packages/core-bridge/src/envconfig.rs b/packages/core-bridge/src/envconfig.rs new file mode 100644 index 000000000..b82dd9d3a --- /dev/null +++ b/packages/core-bridge/src/envconfig.rs @@ -0,0 +1,201 @@ +use std::collections::HashMap; + +use bridge_macros::{TryIntoJs, js_function}; +use neon::prelude::*; +use serde::Serialize; +use temporal_sdk_core_api::envconfig::{ + self, ClientConfig as CoreClientConfig, ClientConfigCodec as CoreClientConfigCodec, + ClientConfigProfile as CoreClientConfigProfile, ClientConfigTLS as CoreClientConfigTLS, + DataSource as CoreDataSource, +}; + +use crate::helpers::{BridgeError, BridgeResult}; + +pub fn init(cx: &mut ModuleContext) -> NeonResult<()> { + cx.export_function("loadClientConfig", load_client_config)?; + cx.export_function("loadClientConnectConfig", load_client_connect_config)?; + Ok(()) +} + +impl From for BridgeError { + fn from(e: envconfig::ConfigError) -> Self { + Self::TypeError { + field: None, + message: e.to_string(), + } + } +} + +#[derive(TryIntoJs, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClientConfig { + profiles: HashMap, +} + +impl From for ClientConfig { + fn from(c: CoreClientConfig) -> Self { + Self { + profiles: c.profiles.into_iter().map(|(k, v)| (k, v.into())).collect(), + } + } +} + +#[derive(TryIntoJs, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClientConfigProfile { + address: Option, + namespace: Option, + api_key: Option, + tls: Option, + codec: Option, + grpc_meta: HashMap, +} + +impl From for ClientConfigProfile { + fn from(c: CoreClientConfigProfile) -> Self { + Self { + address: c.address, + namespace: c.namespace, + api_key: c.api_key, + tls: c.tls.map(Into::into), + codec: c.codec.map(Into::into), + grpc_meta: c.grpc_meta, + } + } +} + +#[derive(TryIntoJs, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClientConfigTls { + disabled: bool, + client_cert: Option, + client_key: Option, + server_ca_cert: Option, + server_name: Option, + disable_host_verification: bool, +} + +impl From for ClientConfigTls { + fn from(c: CoreClientConfigTLS) -> Self { + Self { + disabled: c.disabled, + client_cert: c.client_cert.map(Into::into), + client_key: c.client_key.map(Into::into), + server_ca_cert: c.server_ca_cert.map(Into::into), + server_name: c.server_name, + disable_host_verification: c.disable_host_verification, + } + } +} + +#[derive(TryIntoJs, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClientConfigCodec { + endpoint: Option, + auth: Option, +} + +impl From for ClientConfigCodec { + fn from(c: CoreClientConfigCodec) -> Self { + Self { + endpoint: c.endpoint, + auth: c.auth, + } + } +} + +#[derive(TryIntoJs, Serialize, Default)] +#[serde(rename_all = "camelCase")] +struct DataSource { + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option>, +} + +impl From for DataSource { + fn from(c: CoreDataSource) -> Self { + match c { + CoreDataSource::Path(p) => Self { + path: Some(p), + data: None, + }, + CoreDataSource::Data(d) => Self { + path: None, + data: Some(d), + }, + } + } +} + +// Bridge functions //////////////////////////////////////////////////////////////////////////////// + +/// Load all client profiles from given sources +#[js_function] +fn load_client_config( + path: Option, + data: Option>, + disable_file: bool, + config_file_strict: bool, + env_vars: Option>, +) -> BridgeResult { + let config_source = match (path, data) { + (Some(p), None) => Some(CoreDataSource::Path(p)), + (None, Some(d)) => Some(CoreDataSource::Data(d)), + (None, None) => None, + (Some(_), Some(_)) => { + return Err(BridgeError::TypeError { + field: None, + message: "Cannot specify both path and data for config source".to_string(), + }); + } + }; + + let core_config = if disable_file { + CoreClientConfig::default() + } else { + let options = envconfig::LoadClientConfigOptions { + config_source, + config_file_strict, + }; + envconfig::load_client_config(options, env_vars.as_ref())? + }; + + Ok(core_config.into()) +} + +/// Load a single client profile +#[js_function] +fn load_client_connect_config( + profile: Option, + path: Option, + data: Option>, + disable_file: bool, + disable_env: bool, + config_file_strict: bool, + env_vars: Option>, +) -> BridgeResult { + let config_source = match (path, data) { + (Some(p), None) => Some(CoreDataSource::Path(p)), + (None, Some(d)) => Some(CoreDataSource::Data(d)), + (None, None) => None, + (Some(_), Some(_)) => { + return Err(BridgeError::TypeError { + field: None, + message: "Cannot specify both path and data for config source".to_string(), + }); + } + }; + + let options = envconfig::LoadClientConfigProfileOptions { + config_source, + config_file_profile: profile, + config_file_strict, + disable_file, + disable_env, + }; + + let profile = envconfig::load_client_config_profile(options, env_vars.as_ref())?; + + Ok(profile.into()) +} diff --git a/packages/core-bridge/src/helpers/try_into_js.rs b/packages/core-bridge/src/helpers/try_into_js.rs index fb0e3ca04..9dbd1e8f0 100644 --- a/packages/core-bridge/src/helpers/try_into_js.rs +++ b/packages/core-bridge/src/helpers/try_into_js.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -8,7 +9,8 @@ use neon::{ prelude::Context, result::JsResult, types::{ - JsArray, JsBigInt, JsBoolean, JsBuffer, JsNumber, JsString, JsUndefined, JsValue, Value, + JsArray, JsBigInt, JsBoolean, JsBuffer, JsNumber, JsObject, JsString, JsUndefined, JsValue, + Value, }, }; @@ -64,6 +66,19 @@ impl TryIntoJs for Vec { } } +impl TryIntoJs for HashMap { + type Output = JsObject; + + fn try_into_js<'a>(self, cx: &mut impl Context<'a>) -> JsResult<'a, Self::Output> { + let obj = cx.empty_object(); + for (k, v) in self { + let val = v.try_into_js(cx)?; + obj.set(cx, k.as_str(), val)?; + } + Ok(obj) + } +} + impl TryIntoJs for Vec { type Output = JsBuffer; fn try_into_js<'a>(self, cx: &mut impl Context<'a>) -> JsResult<'a, JsBuffer> { diff --git a/packages/core-bridge/src/lib.rs b/packages/core-bridge/src/lib.rs index 5c7b46404..9e5d47a08 100644 --- a/packages/core-bridge/src/lib.rs +++ b/packages/core-bridge/src/lib.rs @@ -16,6 +16,7 @@ pub mod helpers; mod client; +mod envconfig; mod logs; mod metrics; mod runtime; @@ -25,6 +26,7 @@ mod worker; #[neon::main] fn main(mut cx: neon::prelude::ModuleContext) -> neon::prelude::NeonResult<()> { client::init(&mut cx)?; + envconfig::init(&mut cx)?; logs::init(&mut cx)?; metrics::init(&mut cx)?; runtime::init(&mut cx)?; diff --git a/packages/core-bridge/ts/native.ts b/packages/core-bridge/ts/native.ts index 6bd4fa002..3e5a21285 100644 --- a/packages/core-bridge/ts/native.ts +++ b/packages/core-bridge/ts/native.ts @@ -500,3 +500,75 @@ export declare function setMetricGaugeF64Value( value: number, attrs: JsonString ): void; + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Environment Configuration +//////////////////////////////////////////////////////////////////////////////////////////////////// + +export declare function loadClientConfig( + path: Option, + data: Option, + disableFile: boolean, + configFileStrict: boolean, + envVars: Option> +): ClientConfig; + +export declare function loadClientConnectConfig( + profile: Option, + path: Option, + data: Option, + disableFile: boolean, + disableEnv: boolean, + configFileStrict: boolean, + envVars: Option> +): ClientConfigProfile; + +export interface LoadClientConfigOptions { + path: Option; + data: Option; + disableFile: boolean; + configFileStrict: boolean; + envVars: Option>; +} + +export interface LoadClientConnectConfigOptions { + profile: Option; + path: Option; + data: Option; + disableFile: boolean; + disableEnv: boolean; + configFileStrict: boolean; + envVars: Option>; +} + +export interface DataSource { + path: Option; + data: Option; +} + +export interface ClientConfig { + profiles: Record; +} + +export interface ClientConfigProfile { + address: Option; + namespace: Option; + apiKey: Option; + tls: Option; + codec: Option; + grpcMeta: Record; +} + +export interface ClientConfigTLS { + disabled: boolean; + clientCert: Option; + clientKey: Option; + serverCaCert: Option; + serverName: Option; + disableHostVerification: boolean; +} + +export interface ClientConfigCodec { + endpoint: Option; + auth: Option; +} diff --git a/packages/test/src/test-envconfig.ts b/packages/test/src/test-envconfig.ts new file mode 100644 index 000000000..b52ded2ba --- /dev/null +++ b/packages/test/src/test-envconfig.ts @@ -0,0 +1,583 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import test from 'ava'; +import dedent from 'dedent'; +import { ClientConfig, ClientConfigProfile, ClientConfigTLS, type DataSource } from '@temporalio/client/lib/envconfig'; +import { Connection, Client } from '@temporalio/client'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; + +// 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): DataSource { + return { path: p }; +} +function dataSource(d: Buffer | string): DataSource { + return { data: typeof d === 'string' ? Buffer.from(d) : d }; +} + +// Load default profile from file +test('ClientConfigProfile loads the default profile from a file', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const profile = ClientConfigProfile.load({ 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.deepEqual(profile.grpcMeta, {}); + + const { connectionOptions, namespace } = profile.toClientConnectConfig(); + t.is(connectionOptions.address, 'default-address'); + t.is(namespace, 'default-namespace'); + t.is(connectionOptions.apiKey, undefined); + t.is(connectionOptions.tls, undefined); + t.deepEqual(connectionOptions.metadata, {}); + }); +}); + +// Load custom profile from file +test('ClientConfigProfile loads a custom profile from a file', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const profile = ClientConfigProfile.load({ 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 } = profile.toClientConnectConfig(); + 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'); + }); +}); + +// Load profiles from raw TOML data +test('ClientConfigProfile loads profiles from raw TOML data', (t) => { + const profileDefault = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)) }); + t.is(profileDefault.address, 'default-address'); + t.is(profileDefault.namespace, 'default-namespace'); + t.is(profileDefault.tls, undefined); + + const profileCustom = ClientConfigProfile.load({ + profile: 'custom', + configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)), + }); + t.is(profileCustom.address, 'custom-address'); + t.is(profileCustom.namespace, 'custom-namespace'); + t.is(profileCustom.apiKey, 'custom-api-key'); + t.is(profileCustom.tls?.serverName, 'custom-server-name'); +}); + +// Environment variable overrides (including gRPC metadata) +test('ClientConfigProfile environment variables override file settings', (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 = ClientConfigProfile.load({ + 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'); + }); +}); + +// disableEnv prevents env override +test('ClientConfigProfile disableEnv prevents environment variable overrides', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const env = { TEMPORAL_ADDRESS: 'env-address' }; + const profile = ClientConfigProfile.load({ + configSource: pathSource(filepath), + overrideEnvVars: env, + disableEnv: true, + }); + t.is(profile.address, 'default-address'); + }); +}); + +// disableFile supports env-only loading +test('ClientConfigProfile disableFile loads configuration only from environment', (t) => { + const env = { TEMPORAL_ADDRESS: 'env-address', TEMPORAL_NAMESPACE: 'env-namespace' }; + const profile = ClientConfigProfile.load({ + configSource: pathSource('/non_existent_file.toml'), + disableFile: true, + overrideEnvVars: env, + }); + t.is(profile.address, 'env-address'); + t.is(profile.namespace, 'env-namespace'); +}); + +// Non-existent explicit profile errors +test('ClientConfigProfile raises error for non-existent profile', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const err = t.throws(() => + ClientConfigProfile.load({ configSource: pathSource(filepath), profile: 'nonexistent' }) + ); + t.truthy(err); + t.true(String(err?.message).includes("Profile 'nonexistent' not found")); + }); +}); + +// Strict mode fails on unrecognized keys +test('ClientConfigProfile strict mode fails on unrecognized keys', (t) => { + withTempFile(TOML_CONFIG_STRICT_FAIL, (filepath) => { + const err = t.throws(() => + ClientConfigProfile.load({ configSource: pathSource(filepath), configFileStrict: true }) + ); + t.truthy(err); + t.true(String(err?.message).includes('unrecognized_field')); + }); +}); + +// toClientConnectConfig throws if address missing +test('ClientConfigProfile toClientConnectConfig throws if address is missing', (t) => { + const profile = ClientConfigProfile.load({ configSource: dataSource(Buffer.from('[profile.default]')) }); + t.throws(() => profile.toClientConnectConfig(), { + message: "Configuration profile must contain an 'address' to be used for client connection", + }); +}); + +// TLS from data + disabled handling +test('ClientConfigProfile parses detailed TLS options', (t) => { + const configSource = dataSource(Buffer.from(TOML_CONFIG_TLS_DETAILED)); + + const profileDisabled = ClientConfigProfile.load({ configSource, profile: 'tls_disabled' }); + t.truthy(profileDisabled.tls); + t.true(!!profileDisabled.tls?.disabled); + const { connectionOptions: connOptsDisabled } = profileDisabled.toClientConnectConfig(); + t.is(connOptsDisabled.tls, undefined); + + const profileCerts = ClientConfigProfile.load({ configSource, profile: 'tls_with_certs' }); + t.truthy(profileCerts.tls); + t.is(profileCerts.tls?.serverName, 'custom-server'); + t.deepEqual(profileCerts.tls?.serverRootCaCert, dataSource('ca-pem-data')); + t.deepEqual(profileCerts.tls?.clientCert, dataSource('client-crt-data')); + t.deepEqual(profileCerts.tls?.clientPrivateKey, dataSource('client-key-data')); + + const { connectionOptions: connOptsCerts } = profileCerts.toClientConnectConfig(); + 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'); + } +}); + +// TLS from file paths +test('ClientConfigProfile parses TLS options from 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 = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(tomlConfig)) }); + t.truthy(profile.tls); + t.is(profile.tls?.serverName, 'custom-server'); + t.deepEqual(profile.tls?.serverRootCaCert, { path: caPath }); + t.deepEqual(profile.tls?.clientCert, { path: certPath }); + t.deepEqual(profile.tls?.clientPrivateKey, { path: keyPath }); + + const { connectionOptions: connOpts } = profile.toClientConnectConfig(); + 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'); + } + }); + }); + }); +}); + +// API key auto-enables TLS +test('API key presence auto-enables TLS', (t) => { + const toml = dedent` + [profile.default] + address = "my-address" + api_key = "my-api-key" + `; + const profile = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(toml)) }); + t.truthy(profile.tls); + t.false(!!profile.tls?.disabled); + const { connectionOptions } = profile.toClientConnectConfig(); + t.truthy(connectionOptions.tls); +}); + +// Load all profiles via ClientConfig.load +test('ClientConfig.load loads multiple profiles and maps correctly', (t) => { + const conf = ClientConfig.load({ 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'); +}); + +// gRPC metadata: normalization from TOML and deletion via env + +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 = ClientConfig.load({ 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 = ClientConfigProfile.load({ 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')); +}); + +// Config discovery and disabling + +test('ClientConfig.load discovers config via TEMPORAL_CONFIG_FILE', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const conf = ClientConfig.load({ overrideEnvVars: { TEMPORAL_CONFIG_FILE: filepath } }); + t.truthy(conf.profiles['default']); + t.is(conf.profiles['default'].address, 'default-address'); + }); +}); + +test('ClientConfig.load with disable_file ignores discovery and returns empty', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const conf = ClientConfig.load({ disableFile: true, overrideEnvVars: { TEMPORAL_CONFIG_FILE: filepath } }); + t.deepEqual(conf.profiles, {}); + }); +}); + +// Profile existence semantics + +test('Default profile not found returns empty profile', (t) => { + const toml = dedent` + [profile.existing] + address = "my-address" + `; + const prof = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(toml)) }); + t.is(prof.address, undefined); + t.is(prof.namespace, undefined); + t.is(prof.apiKey, undefined); + t.deepEqual(prof.grpcMeta, {}); + t.is(prof.tls, undefined); +}); + +// TLS conflict cases + +test('TLS conflict in TOML: both path and data should error', (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(() => ClientConfigProfile.load({ 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(() => + ClientConfigProfile.load({ 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(() => + ClientConfigProfile.load({ configSource: dataSource(Buffer.from(toml)), overrideEnvVars: env }) + ); + t.truthy(err); + t.true( + String(err?.message) + .toLowerCase() + .includes('data') + ); +}); + +// Strictness: unrecognized table + +test('ClientConfig.load strict mode fails on unrecognized table', (t) => { + const toml = dedent` + [unrecognized_table] + foo = "bar" + `; + const err = t.throws(() => + ClientConfig.load({ configSource: dataSource(Buffer.from(toml)), configFileStrict: true }) + ); + t.truthy(err); + t.true(String(err?.message).includes('unrecognized_table')); +}); + +// JSON roundtrips + +test('ClientConfigProfile toJSON/fromJSON roundtrip', (t) => { + const profile = new ClientConfigProfile({ + address: 'some-address', + namespace: 'some-namespace', + apiKey: 'some-api-key', + tls: new ClientConfigTLS({ + serverName: 'some-server', + serverRootCaCert: { data: Buffer.from('ca') }, + clientCert: { path: '/path/to/client.crt' }, + clientPrivateKey: { data: Buffer.from('key') }, + }), + grpcMeta: { 'some-header': 'some-value' }, + }); + const json = profile.toJSON(); + const back = ClientConfigProfile.fromJSON(json); + 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'); + t.deepEqual(back.tls?.serverRootCaCert, { data: 'ca' }); + t.deepEqual(back.tls?.clientCert, { path: '/path/to/client.crt' }); + t.deepEqual(back.tls?.clientPrivateKey, { data: 'key' }); + t.is(back.grpcMeta['some-header'], 'some-value'); +}); + +test('ClientConfig toJSON/fromJSON roundtrip', (t) => { + const conf = new ClientConfig({ + default: new ClientConfigProfile({ address: 'addr', namespace: 'ns' }), + custom: new ClientConfigProfile({ address: 'addr2', apiKey: 'key2', grpcMeta: { h: 'v' } }), + } as any); + const json = conf.toJSON(); + const back = ClientConfig.fromJSON(json); + 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'); +}); + +// Convenience API + +test('ClientConfig.loadClientConnectConfig works with file path and env overrides', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + // From file + let cc = ClientConfig.loadClientConnectConfig({ configSource: pathSource(filepath) }); + t.is(cc.connectionOptions.address, 'default-address'); + t.is(cc.namespace, 'default-namespace'); + + // With env overrides + cc = ClientConfig.loadClientConnectConfig({ + configSource: pathSource(filepath), + overrideEnvVars: { TEMPORAL_NAMESPACE: 'env-namespace-override' }, + }); + t.is(cc.namespace, 'env-namespace-override'); + }); +}); + +// Malformed TOML for ClientConfig.load + +test('ClientConfig.load raises error for malformed TOML', (t) => { + const err = t.throws(() => ClientConfig.load({ configSource: dataSource(Buffer.from('this is not valid toml')) })); + t.truthy(err); + t.true( + String(err?.message) + .toLowerCase() + .includes('toml') + ); +}); + +// E2E test: Load config and create client connection + +test('ClientConfig.loadClientConnectConfig creates working client connection', 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 } = ClientConfig.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('ClientConfigProfile with TLS can create client connection', async (t) => { + // Start a local test server + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Create TOML config with TLS disabled (local test server doesn't use TLS) + const toml = dedent` + [profile.default] + address = "${address}" + namespace = "default" + [profile.default.tls] + disabled = true + `; + + // Load config via profile + const profile = ClientConfigProfile.load({ + configSource: dataSource(Buffer.from(toml)), + }); + + const { connectionOptions, namespace } = profile.toClientConnectConfig(); + + // Create connection and client + const connection = await Connection.connect(connectionOptions); + const client = new Client({ + connection, + namespace: namespace || 'default', + }); + + // Verify connection works + t.truthy(client); + t.is(connectionOptions.tls, undefined); // disabled TLS results in undefined + + // Clean up + await connection.close(); + } finally { + await env.teardown(); + } +}); From 396580648a832a56a274920fbe286826080d6ae3 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 11 Aug 2025 22:50:11 -0400 Subject: [PATCH 2/9] fix build --- packages/client/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 328970e2d..6fe186ac7 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -4,6 +4,6 @@ "outDir": "./lib", "rootDir": "./src" }, - "references": [{ "path": "../common" }], + "references": [{ "path": "../common" }, { "path": "../core-bridge" }], "include": ["./src/**/*.ts"] } From c41bc768a00852965bc997ec0d976c06fc7f942d Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 18 Aug 2025 16:10:18 -0400 Subject: [PATCH 3/9] small fixes, improve/add doc strings --- packages/client/src/envconfig.ts | 105 ++++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 14 deletions(-) diff --git a/packages/client/src/envconfig.ts b/packages/client/src/envconfig.ts index f1d2ad9c8..11969ff0b 100644 --- a/packages/client/src/envconfig.ts +++ b/packages/client/src/envconfig.ts @@ -9,13 +9,11 @@ import type { ConnectionOptions } from './connection'; * * It is defined as a tagged union, to be consumed by other parts of the SDK. */ - export type DataSource = { path: string } | { data: string | Buffer }; interface ClientConfigTLSJSON { disabled?: boolean; serverName?: string; - // TODO(assess): name Ca -> CA ? serverCaCert?: Record; clientCert?: Record; clientKey?: Record; @@ -29,14 +27,14 @@ interface ClientConfigProfileJSON { grpcMeta?: Record; } -function recordToSource(d?: Record): DataSource | undefined { +function recordToSource(d?: Record): DataSource | undefined { if (d === undefined) { return undefined; } - if (d.data !== undefined) { + if (d.data != null) { return { data: d.data }; } - if (d.path !== undefined && typeof d.path === 'string') { + if (d.path != null && typeof d.path === 'string') { return { path: d.path }; } return undefined; @@ -83,7 +81,6 @@ function readSourceSync(source?: DataSource): Buffer | undefined { } if ('path' in source) { - // This is correct. It reads the whole file into a Buffer asynchronously. return fs.readFileSync(source.path); } @@ -198,7 +195,12 @@ export class ClientConfigTLS { }); } - // TODO(assess): add doc string + /** + * Converts this TLS configuration to a {@link TLSConfig} object for use with connections. + * + * @returns A TLSConfig object with the certificate data loaded from the configured sources, + * or undefined if TLS is disabled. + */ public toTLSConfig(): TLSConfig | undefined { if (this.disabled) { return undefined; @@ -222,7 +224,6 @@ export class ClientConfigTLS { } } -// TODO(assess): determine need for "ClientConnectConfig" interface ClientConnectConfig { connectionOptions: ConnectionOptions; namespace?: string; @@ -269,7 +270,33 @@ export interface LoadClientProfileOptions { overrideEnvVars?: Record; } -// TODO(assess): docstring +/** + * A client configuration profile loaded from environment variables and/or TOML configuration. + * + * This class represents a single named profile within a client configuration, containing + * all the necessary settings to establish a connection to a Temporal server, including + * address, namespace, authentication, TLS settings, and gRPC metadata. + * + * Configuration sources are loaded and merged in the following precedence (highest to lowest): + * 1. Environment variable overrides + * 2. TOML configuration file settings + * 3. Default values + * + * @example + * ```ts + * // Load the default profile from environment and default config locations + * const profile = ClientConfigProfile.load(); + * const { connectionOptions, namespace } = profile.toClientConnectConfig(); + * + * // Load a specific profile with custom config source + * const customProfile = ClientConfigProfile.load({ + * profile: 'production', + * configSource: { path: '/path/to/config.toml' } + * }); + * ``` + * + * @experimental + */ export class ClientConfigProfile { public readonly address?: string; public readonly namespace?: string; @@ -322,7 +349,24 @@ export class ClientConfigProfile { }); } - // TODO(assess): docstring + /** + * Converts this configuration profile to connection options for creating a Temporal client. + * + * This method validates that required settings are present and transforms the profile + * into the format expected by {@link Connection.connect} and related client constructors. + * + * @returns An object containing connection options and namespace settings + * @throws {Error} If the profile does not contain a required address field + * + * @example + * ```ts + * const profile = ClientConfigProfile.load({ profile: 'production' }); + * const { connectionOptions, namespace } = profile.toClientConnectConfig(); + * + * const connection = await Connection.connect(connectionOptions); + * const client = new Client({ connection, namespace }); + * ``` + */ public toClientConnectConfig(): ClientConnectConfig { if (!this.address) { throw new Error("Configuration profile must contain an 'address' to be used for client connection"); @@ -372,12 +416,45 @@ interface ClientConfigJSON { profiles: Record; } -// TODO(assess): update docstrings /** - * Client configuration loaded from TOML and environment variables. + * Client configuration container that manages multiple named profiles. + * + * This class loads and manages client configuration profiles from TOML files and + * environment variables, providing a centralized way to handle multiple Temporal + * server connection configurations. Each profile contains settings for server + * address, authentication, TLS, and other connection parameters. + * + * The configuration supports: + * - Loading from TOML configuration files + * - Environment variable-based profile discovery via `TEMPORAL_CONFIG_FILE` + * - Multiple named profiles within a single configuration + * - JSON serialization for configuration persistence + * - Strict mode validation for configuration files + * + * This is the TypeScript equivalent of the Python SDK's `ClientConfig`. * - * This class is the TypeScript equivalent of the Python SDK's `ClientConfig`. - * It contains a mapping of profile names to client profiles. + * @example + * ```ts + * // Load all profiles from default config locations + * const config = ClientConfig.load(); + * + * // Access a specific profile + * const prodProfile = config.profiles['production']; + * if (prodProfile) { + * const { connectionOptions, namespace } = prodProfile.toClientConnectConfig(); + * } + * + * // Load from a specific TOML file with strict validation + * const strictConfig = ClientConfig.load({ + * configSource: { path: './temporal-config.toml' }, + * configFileStrict: true + * }); + * + * // Convenience method to load a single profile directly + * const connectConfig = ClientConfig.loadClientConnectConfig({ + * profile: 'development' + * }); + * ``` * * @experimental */ From d0d2fec1c4f7a5d1875fdb6d1065dea4f4f9295f Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 18 Aug 2025 16:32:51 -0400 Subject: [PATCH 4/9] linting & formatting --- packages/client/src/envconfig.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/client/src/envconfig.ts b/packages/client/src/envconfig.ts index 11969ff0b..c534992de 100644 --- a/packages/client/src/envconfig.ts +++ b/packages/client/src/envconfig.ts @@ -287,14 +287,14 @@ export interface LoadClientProfileOptions { * // Load the default profile from environment and default config locations * const profile = ClientConfigProfile.load(); * const { connectionOptions, namespace } = profile.toClientConnectConfig(); - * + * * // Load a specific profile with custom config source * const customProfile = ClientConfigProfile.load({ * profile: 'production', * configSource: { path: '/path/to/config.toml' } * }); * ``` - * + * * @experimental */ export class ClientConfigProfile { @@ -362,7 +362,7 @@ export class ClientConfigProfile { * ```ts * const profile = ClientConfigProfile.load({ profile: 'production' }); * const { connectionOptions, namespace } = profile.toClientConnectConfig(); - * + * * const connection = await Connection.connect(connectionOptions); * const client = new Client({ connection, namespace }); * ``` @@ -425,7 +425,7 @@ interface ClientConfigJSON { * address, authentication, TLS, and other connection parameters. * * The configuration supports: - * - Loading from TOML configuration files + * - Loading from TOML configuration files * - Environment variable-based profile discovery via `TEMPORAL_CONFIG_FILE` * - Multiple named profiles within a single configuration * - JSON serialization for configuration persistence @@ -437,19 +437,19 @@ interface ClientConfigJSON { * ```ts * // Load all profiles from default config locations * const config = ClientConfig.load(); - * + * * // Access a specific profile * const prodProfile = config.profiles['production']; * if (prodProfile) { * const { connectionOptions, namespace } = prodProfile.toClientConnectConfig(); * } - * + * * // Load from a specific TOML file with strict validation * const strictConfig = ClientConfig.load({ * configSource: { path: './temporal-config.toml' }, * configFileStrict: true * }); - * + * * // Convenience method to load a single profile directly * const connectConfig = ClientConfig.loadClientConnectConfig({ * profile: 'development' From 3b00251a9517ad1c66b78fd59327e35a19fc3ac0 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Sat, 23 Aug 2025 02:26:33 -0400 Subject: [PATCH 5/9] improve test suite --- packages/client/src/envconfig.ts | 4 - packages/test/src/test-envconfig.ts | 603 ++++++++++++++++++++-------- 2 files changed, 432 insertions(+), 175 deletions(-) diff --git a/packages/client/src/envconfig.ts b/packages/client/src/envconfig.ts index c534992de..fb6c004de 100644 --- a/packages/client/src/envconfig.ts +++ b/packages/client/src/envconfig.ts @@ -368,10 +368,6 @@ export class ClientConfigProfile { * ``` */ public toClientConnectConfig(): ClientConnectConfig { - if (!this.address) { - throw new Error("Configuration profile must contain an 'address' to be used for client connection"); - } - const tlsConfig = this.tls?.toTLSConfig(); return { diff --git a/packages/test/src/test-envconfig.ts b/packages/test/src/test-envconfig.ts index b52ded2ba..5c8c6bc8a 100644 --- a/packages/test/src/test-envconfig.ts +++ b/packages/test/src/test-envconfig.ts @@ -63,8 +63,11 @@ function dataSource(d: Buffer | string): DataSource { return { data: typeof d === 'string' ? Buffer.from(d) : d }; } -// Load default profile from file -test('ClientConfigProfile loads the default profile from a file', (t) => { +// ============================================================================= +// 🔧 PROFILE LOADING +// ============================================================================= + +test('Load default profile from file', (t) => { withTempFile(TOML_CONFIG_BASE, (filepath) => { const profile = ClientConfigProfile.load({ configSource: pathSource(filepath) }); t.is(profile.address, 'default-address'); @@ -82,8 +85,7 @@ test('ClientConfigProfile loads the default profile from a file', (t) => { }); }); -// Load custom profile from file -test('ClientConfigProfile loads a custom profile from a file', (t) => { +test('Load custom profile from file', (t) => { withTempFile(TOML_CONFIG_BASE, (filepath) => { const profile = ClientConfigProfile.load({ profile: 'custom', configSource: pathSource(filepath) }); t.is(profile.address, 'custom-address'); @@ -107,25 +109,38 @@ test('ClientConfigProfile loads a custom profile from a file', (t) => { }); }); -// Load profiles from raw TOML data -test('ClientConfigProfile loads profiles from raw TOML data', (t) => { - const profileDefault = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)) }); - t.is(profileDefault.address, 'default-address'); - t.is(profileDefault.namespace, 'default-namespace'); - t.is(profileDefault.tls, undefined); +test('Load default profile from data', (t) => { + const profile = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)) }); + t.is(profile.address, 'default-address'); + t.is(profile.namespace, 'default-namespace'); + t.is(profile.tls, undefined); +}); - const profileCustom = ClientConfigProfile.load({ +test('Load custom profile from data', (t) => { + const profile = ClientConfigProfile.load({ profile: 'custom', configSource: dataSource(Buffer.from(TOML_CONFIG_BASE)), }); - t.is(profileCustom.address, 'custom-address'); - t.is(profileCustom.namespace, 'custom-namespace'); - t.is(profileCustom.apiKey, 'custom-api-key'); - t.is(profileCustom.tls?.serverName, 'custom-server-name'); + 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'); }); -// Environment variable overrides (including gRPC metadata) -test('ClientConfigProfile environment variables override file settings', (t) => { +test('Load profile from data with env overrides', (t) => { + const env = { + TEMPORAL_ADDRESS: 'env-address', + TEMPORAL_NAMESPACE: 'env-namespace', + }; + const profile = ClientConfigProfile.load({ + 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', @@ -151,8 +166,77 @@ test('ClientConfigProfile environment variables override file settings', (t) => }); }); -// disableEnv prevents env override -test('ClientConfigProfile disableEnv prevents environment variable overrides', (t) => { +test('Load profiles with string content', (t) => { + const stringContent = TOML_CONFIG_BASE; + const profile = ClientConfigProfile.load({ configSource: dataSource(stringContent) }); + t.is(profile.address, 'default-address'); + t.is(profile.namespace, 'default-namespace'); + + // Test with custom profile from string + const profileCustom = ClientConfigProfile.load({ + profile: 'custom', + configSource: dataSource(stringContent), + }); + t.is(profileCustom.address, 'custom-address'); + t.is(profileCustom.apiKey, 'custom-api-key'); +}); + +// ============================================================================= +// 🌍 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 = ClientConfigProfile.load({ + 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 = ClientConfig.load({ 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 = ClientConfigProfile.load({ 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 = ClientConfigProfile.load({ @@ -164,8 +248,11 @@ test('ClientConfigProfile disableEnv prevents environment variable overrides', ( }); }); -// disableFile supports env-only loading -test('ClientConfigProfile disableFile loads configuration only from environment', (t) => { +// ============================================================================= +// 🎛️ CONTROL FLAGS +// ============================================================================= + +test('Load profile with disabled file flag', (t) => { const env = { TEMPORAL_ADDRESS: 'env-address', TEMPORAL_NAMESPACE: 'env-namespace' }; const profile = ClientConfigProfile.load({ configSource: pathSource('/non_existent_file.toml'), @@ -176,38 +263,121 @@ test('ClientConfigProfile disableFile loads configuration only from environment' t.is(profile.namespace, 'env-namespace'); }); -// Non-existent explicit profile errors -test('ClientConfigProfile raises error for non-existent profile', (t) => { +test('Load profiles without profile-level env overrides', (t) => { withTempFile(TOML_CONFIG_BASE, (filepath) => { - const err = t.throws(() => - ClientConfigProfile.load({ configSource: pathSource(filepath), profile: 'nonexistent' }) - ); - t.truthy(err); - t.true(String(err?.message).includes("Profile 'nonexistent' not found")); + const env = { TEMPORAL_ADDRESS: 'should-be-ignored' }; + // ClientConfig.load doesn't apply env overrides, so we test it loads correctly + const conf = ClientConfig.load({ + configSource: pathSource(filepath), + overrideEnvVars: env, + }); + t.is(conf.profiles['default'].address, 'default-address'); + + // Test that profile-level loading with disableEnv ignores environment + const profile = ClientConfigProfile.load({ + configSource: pathSource(filepath), + overrideEnvVars: env, + disableEnv: true, + }); + t.is(profile.address, 'default-address'); }); }); -// Strict mode fails on unrecognized keys -test('ClientConfigProfile strict mode fails on unrecognized keys', (t) => { - withTempFile(TOML_CONFIG_STRICT_FAIL, (filepath) => { - const err = t.throws(() => - ClientConfigProfile.load({ configSource: pathSource(filepath), configFileStrict: true }) - ); - t.truthy(err); - t.true(String(err?.message).includes('unrecognized_field')); +test('Cannot disable both file and env override flags', (t) => { + const err = t.throws(() => + ClientConfigProfile.load({ + 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 = ClientConfig.load({ 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 = ClientConfig.load({ 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, with disable file flag', (t) => { + const conf = ClientConfig.load({ + configSource: pathSource('/non_existent_file.toml'), + disableFile: true, + }); + t.deepEqual(conf.profiles, {}); +}); + +test('Load all profiles with overridden file path', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const conf = ClientConfig.load({ overrideEnvVars: { TEMPORAL_CONFIG_FILE: filepath } }); + t.truthy(conf.profiles['default']); + t.is(conf.profiles['default'].address, 'default-address'); }); }); -// toClientConnectConfig throws if address missing -test('ClientConfigProfile toClientConnectConfig throws if address is missing', (t) => { - const profile = ClientConfigProfile.load({ configSource: dataSource(Buffer.from('[profile.default]')) }); - t.throws(() => profile.toClientConnectConfig(), { - message: "Configuration profile must contain an 'address' to be used for client connection", +test('Load profiles with overridden file path - disabled file flag enabled', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const conf = ClientConfig.load({ disableFile: true, overrideEnvVars: { TEMPORAL_CONFIG_FILE: filepath } }); + t.deepEqual(conf.profiles, {}); }); }); -// TLS from data + disabled handling -test('ClientConfigProfile parses detailed TLS options', (t) => { +test('Default profile not found returns empty profile', (t) => { + const toml = dedent` + [profile.existing] + address = "my-address" + `; + const prof = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(toml)) }); + t.is(prof.address, undefined); + t.is(prof.namespace, undefined); + t.is(prof.apiKey, undefined); + t.deepEqual(prof.grpcMeta, {}); + t.is(prof.tls, undefined); +}); + +// ============================================================================= +// 🔐 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 = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(toml)) }); + t.truthy(profile.tls); + t.false(!!profile.tls?.disabled); + const { connectionOptions } = profile.toClientConnectConfig(); + t.truthy(connectionOptions.tls); +}); + +test('Load profile with TLS options', (t) => { const configSource = dataSource(Buffer.from(TOML_CONFIG_TLS_DETAILED)); const profileDisabled = ClientConfigProfile.load({ configSource, profile: 'tls_disabled' }); @@ -235,8 +405,7 @@ test('ClientConfigProfile parses detailed TLS options', (t) => { } }); -// TLS from file paths -test('ClientConfigProfile parses TLS options from file paths', (t) => { +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) => { @@ -271,97 +440,7 @@ test('ClientConfigProfile parses TLS options from file paths', (t) => { }); }); -// API key auto-enables TLS -test('API key presence auto-enables TLS', (t) => { - const toml = dedent` - [profile.default] - address = "my-address" - api_key = "my-api-key" - `; - const profile = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(toml)) }); - t.truthy(profile.tls); - t.false(!!profile.tls?.disabled); - const { connectionOptions } = profile.toClientConnectConfig(); - t.truthy(connectionOptions.tls); -}); - -// Load all profiles via ClientConfig.load -test('ClientConfig.load loads multiple profiles and maps correctly', (t) => { - const conf = ClientConfig.load({ 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'); -}); - -// gRPC metadata: normalization from TOML and deletion via env - -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 = ClientConfig.load({ 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 = ClientConfigProfile.load({ 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')); -}); - -// Config discovery and disabling - -test('ClientConfig.load discovers config via TEMPORAL_CONFIG_FILE', (t) => { - withTempFile(TOML_CONFIG_BASE, (filepath) => { - const conf = ClientConfig.load({ overrideEnvVars: { TEMPORAL_CONFIG_FILE: filepath } }); - t.truthy(conf.profiles['default']); - t.is(conf.profiles['default'].address, 'default-address'); - }); -}); - -test('ClientConfig.load with disable_file ignores discovery and returns empty', (t) => { - withTempFile(TOML_CONFIG_BASE, (filepath) => { - const conf = ClientConfig.load({ disableFile: true, overrideEnvVars: { TEMPORAL_CONFIG_FILE: filepath } }); - t.deepEqual(conf.profiles, {}); - }); -}); - -// Profile existence semantics - -test('Default profile not found returns empty profile', (t) => { - const toml = dedent` - [profile.existing] - address = "my-address" - `; - const prof = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(toml)) }); - t.is(prof.address, undefined); - t.is(prof.namespace, undefined); - t.is(prof.apiKey, undefined); - t.deepEqual(prof.grpcMeta, {}); - t.is(prof.tls, undefined); -}); - -// TLS conflict cases - -test('TLS conflict in TOML: both path and data should error', (t) => { +test('Load profile with conflicting cert source fails', (t) => { const toml = dedent` [profile.default] address = "addr" @@ -412,9 +491,21 @@ test('TLS conflict across sources: data in TOML, path in env should error', (t) ); }); -// Strictness: unrecognized table +// ============================================================================= +// 🚫 ERROR HANDLING +// ============================================================================= + +test('Load non-existent profile', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const err = t.throws(() => + ClientConfigProfile.load({ configSource: pathSource(filepath), profile: 'nonexistent' }) + ); + t.truthy(err); + t.true(String(err?.message).includes("Profile 'nonexistent' not found")); + }); +}); -test('ClientConfig.load strict mode fails on unrecognized table', (t) => { +test('Load invalid config with strict mode enabled', (t) => { const toml = dedent` [unrecognized_table] foo = "bar" @@ -426,9 +517,31 @@ test('ClientConfig.load strict mode fails on unrecognized table', (t) => { t.true(String(err?.message).includes('unrecognized_table')); }); -// JSON roundtrips +test('Load invalid profile with strict mode enabled', (t) => { + withTempFile(TOML_CONFIG_STRICT_FAIL, (filepath) => { + const err = t.throws(() => + ClientConfigProfile.load({ 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(() => ClientConfig.load({ configSource: dataSource(Buffer.from('this is not valid toml')) })); + t.truthy(err); + t.true( + String(err?.message) + .toLowerCase() + .includes('toml') + ); +}); + +// ============================================================================= +// 🔄 SERIALIZATION +// ============================================================================= -test('ClientConfigProfile toJSON/fromJSON roundtrip', (t) => { +test('Client config profile to/from JSON round-trip', (t) => { const profile = new ClientConfigProfile({ address: 'some-address', namespace: 'some-namespace', @@ -454,7 +567,7 @@ test('ClientConfigProfile toJSON/fromJSON roundtrip', (t) => { t.is(back.grpcMeta['some-header'], 'some-value'); }); -test('ClientConfig toJSON/fromJSON roundtrip', (t) => { +test('Client config to/from JSON round-trip', (t) => { const conf = new ClientConfig({ default: new ClientConfigProfile({ address: 'addr', namespace: 'ns' }), custom: new ClientConfigProfile({ address: 'addr2', apiKey: 'key2', grpcMeta: { h: 'v' } }), @@ -468,7 +581,9 @@ test('ClientConfig toJSON/fromJSON roundtrip', (t) => { t.is(back.profiles['custom'].grpcMeta['h'], 'v'); }); -// Convenience API +// ============================================================================= +// 🎯 INTEGRATION/E2E +// ============================================================================= test('ClientConfig.loadClientConnectConfig works with file path and env overrides', (t) => { withTempFile(TOML_CONFIG_BASE, (filepath) => { @@ -486,21 +601,7 @@ test('ClientConfig.loadClientConnectConfig works with file path and env override }); }); -// Malformed TOML for ClientConfig.load - -test('ClientConfig.load raises error for malformed TOML', (t) => { - const err = t.throws(() => ClientConfig.load({ configSource: dataSource(Buffer.from('this is not valid toml')) })); - t.truthy(err); - t.true( - String(err?.message) - .toLowerCase() - .includes('toml') - ); -}); - -// E2E test: Load config and create client connection - -test('ClientConfig.loadClientConnectConfig creates working client connection', async (t) => { +test('Create client from default profile', async (t) => { // Start a local test server const env = await TestWorkflowEnvironment.createLocal(); @@ -541,43 +642,203 @@ test('ClientConfig.loadClientConnectConfig creates working client connection', a } }); -test('ClientConfigProfile with TLS can create client connection', async (t) => { - // Start a local test server +test('Create client from custom profile', async (t) => { const env = await TestWorkflowEnvironment.createLocal(); try { const { address } = env.connection.options; - // Create TOML config with TLS disabled (local test server doesn't use TLS) + // Create basic development profile configuration const toml = dedent` - [profile.default] + [profile.development] address = "${address}" - namespace = "default" - [profile.default.tls] + namespace = "development-namespace" + `; + + // Load profile and create connection + const profile = ClientConfigProfile.load({ + 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 } = profile.toClientConnectConfig(); + 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 config via profile + // Load profile and verify TLS/API key handling const profile = ClientConfigProfile.load({ + 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 } = profile.toClientConnectConfig(); + + // Verify API key is present but TLS is disabled for local testing + t.is(connectionOptions.apiKey, 'prod-api-key-12345'); + t.is(connectionOptions.tls, undefined); // disabled = true results in undefined - // Create connection and client const connection = await Connection.connect(connectionOptions); - const client = new Client({ - connection, - namespace: namespace || 'default', + 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 = ClientConfigProfile.load({ + configSource: dataSource(Buffer.from(toml)), + overrideEnvVars: envOverrides, }); - // Verify connection works + // 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 } = profile.toClientConnectConfig(); + const connection = await Connection.connect(connectionOptions); + const client = new Client({ connection, namespace: namespace || 'default' }); + + // Verify client uses overridden values t.truthy(client); - t.is(connectionOptions.tls, undefined); // disabled TLS results in undefined + t.is(client.options.namespace, 'env-override-namespace'); + t.is(connectionOptions.metadata?.['custom-header'], 'env-header-value'); - // Clean up 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 = ClientConfigProfile.load({ + profile: 'service-a', + configSource: dataSource(Buffer.from(toml)), + }); + + const profileB = ClientConfigProfile.load({ + 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 = profileA.toClientConnectConfig(); + const configB = profileB.toClientConnectConfig(); + + 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(); + } +}); \ No newline at end of file From dc72a65431f0c7f96f8430b12eecaab459ef5b3f Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 25 Aug 2025 12:55:19 -0400 Subject: [PATCH 6/9] formatting --- packages/test/src/test-envconfig.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/test/src/test-envconfig.ts b/packages/test/src/test-envconfig.ts index 5c8c6bc8a..ddb2b15c7 100644 --- a/packages/test/src/test-envconfig.ts +++ b/packages/test/src/test-envconfig.ts @@ -171,7 +171,7 @@ test('Load profiles with string content', (t) => { const profile = ClientConfigProfile.load({ configSource: dataSource(stringContent) }); t.is(profile.address, 'default-address'); t.is(profile.namespace, 'default-namespace'); - + // Test with custom profile from string const profileCustom = ClientConfigProfile.load({ profile: 'custom', @@ -272,7 +272,7 @@ test('Load profiles without profile-level env overrides', (t) => { overrideEnvVars: env, }); t.is(conf.profiles['default'].address, 'default-address'); - + // Test that profile-level loading with disableEnv ignores environment const profile = ClientConfigProfile.load({ configSource: pathSource(filepath), @@ -710,7 +710,7 @@ test('Create client from custom profile with TLS options', async (t) => { t.true(!!profile.tls?.disabled); const { connectionOptions, namespace } = profile.toClientConnectConfig(); - + // Verify API key is present but TLS is disabled for local testing t.is(connectionOptions.apiKey, 'prod-api-key-12345'); t.is(connectionOptions.tls, undefined); // disabled = true results in undefined @@ -841,4 +841,4 @@ test('Create clients from multi-profile config', async (t) => { } finally { await env.teardown(); } -}); \ No newline at end of file +}); From 96393d3493f78173da927430380b1423b7746649 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 25 Aug 2025 13:06:25 -0400 Subject: [PATCH 7/9] fix core commit --- packages/core-bridge/sdk-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-bridge/sdk-core b/packages/core-bridge/sdk-core index 8dd4a20ab..871b320c8 160000 --- a/packages/core-bridge/sdk-core +++ b/packages/core-bridge/sdk-core @@ -1 +1 @@ -Subproject commit 8dd4a20ab06e8dee2208da15a96d60efb1bcf043 +Subproject commit 871b320c8f51d52cb69fcc31f9c4dcd47b9f3961 From 0dcc25d561b8d1431e6fb71587c024f306b25a62 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 15 Sep 2025 14:47:21 -0400 Subject: [PATCH 8/9] Make disabled field in TLS config optional, additional cleanup --- packages/client/src/envconfig.ts | 109 +++--- packages/core-bridge/Cargo.lock | 535 ++++++++------------------ packages/core-bridge/sdk-core | 2 +- packages/core-bridge/src/client.rs | 4 + packages/core-bridge/src/envconfig.rs | 2 +- packages/core-bridge/ts/native.ts | 2 +- packages/test/src/test-envconfig.ts | 104 ++++- 7 files changed, 325 insertions(+), 433 deletions(-) diff --git a/packages/client/src/envconfig.ts b/packages/client/src/envconfig.ts index fb6c004de..12ad1ac9e 100644 --- a/packages/client/src/envconfig.ts +++ b/packages/client/src/envconfig.ts @@ -11,7 +11,7 @@ import type { ConnectionOptions } from './connection'; */ export type DataSource = { path: string } | { data: string | Buffer }; -interface ClientConfigTLSJSON { +interface ClientConfigTLSObject { disabled?: boolean; serverName?: string; serverCaCert?: Record; @@ -19,11 +19,11 @@ interface ClientConfigTLSJSON { clientKey?: Record; } -interface ClientConfigProfileJSON { +interface ClientConfigProfileObject { address?: string; namespace?: string; apiKey?: string; - tls?: ClientConfigTLSJSON; + tls?: ClientConfigTLSObject; grpcMeta?: Record; } @@ -81,7 +81,13 @@ function readSourceSync(source?: DataSource): Buffer | undefined { } if ('path' in source) { - return fs.readFileSync(source.path); + try { + return fs.readFileSync(source.path); + } catch (error) { + throw new Error( + `Failed to read file at path "${source.path}": ${error instanceof Error ? error.message : String(error)}` + ); + } } if (Buffer.isBuffer(source.data)) { @@ -92,6 +98,11 @@ function readSourceSync(source?: DataSource): Buffer | undefined { return Buffer.from(source.data, 'utf8'); } +// Utility to convert null to undefined for optional fields +function nullToUndefined(value: T | null | undefined): T | undefined { + return value ?? undefined; +} + function bridgeToDataSource(bridgeSource?: native.DataSource | null): DataSource | undefined { if (bridgeSource == null) { return undefined; @@ -107,8 +118,8 @@ function bridgeToDataSource(bridgeSource?: native.DataSource | null): DataSource function clientConfigTlsFromBridge(tls: native.ClientConfigProfile['tls']): ClientConfigTLS { return new ClientConfigTLS({ - disabled: tls?.disabled, - serverName: tls?.serverName ?? undefined, + disabled: nullToUndefined(tls?.disabled), + serverName: nullToUndefined(tls?.serverName), serverRootCaCert: bridgeToDataSource(tls?.serverCaCert), clientCert: bridgeToDataSource(tls?.clientCert), clientPrivateKey: bridgeToDataSource(tls?.clientKey), @@ -117,11 +128,11 @@ function clientConfigTlsFromBridge(tls: native.ClientConfigProfile['tls']): Clie function clientConfigProfileFromBridge(profile: native.ClientConfigProfile): ClientConfigProfile { return new ClientConfigProfile({ - address: profile.address ?? undefined, - namespace: profile.namespace ?? undefined, - apiKey: profile.apiKey ?? undefined, + address: nullToUndefined(profile.address), + namespace: nullToUndefined(profile.namespace), + apiKey: nullToUndefined(profile.apiKey), tls: profile.tls ? clientConfigTlsFromBridge(profile.tls) : undefined, - grpcMeta: profile.grpcMeta, + grpcMeta: profile.grpcMeta ?? {}, }); } @@ -151,47 +162,52 @@ export interface ClientConfigTLSOptions { * @experimental */ export class ClientConfigTLS { - public readonly disabled: boolean; + public readonly disabled?: boolean; public readonly serverName?: string; public readonly serverRootCaCert?: DataSource; public readonly clientCert?: DataSource; public readonly clientPrivateKey?: DataSource; constructor(options: ClientConfigTLSOptions) { - this.disabled = options.disabled ?? false; + // Validate that if client cert is provided, private key is also provided + if ((options.clientCert && !options.clientPrivateKey) || (!options.clientCert && options.clientPrivateKey)) { + throw new Error('Client certificate and private key must both be provided or both be omitted'); + } + + this.disabled = options.disabled; this.serverName = options.serverName; this.serverRootCaCert = options.serverRootCaCert; this.clientCert = options.clientCert; this.clientPrivateKey = options.clientPrivateKey; } - public toJSON(): ClientConfigTLSJSON { - const json: ClientConfigTLSJSON = {}; - if (this.disabled) { - json.disabled = this.disabled; + public toObject(): ClientConfigTLSObject { + const obj: ClientConfigTLSObject = {}; + if (this.disabled !== undefined) { + obj.disabled = this.disabled; } if (this.serverName) { - json.serverName = this.serverName; + obj.serverName = this.serverName; } if (this.serverRootCaCert) { - json.serverCaCert = sourceToRecord(this.serverRootCaCert); + obj.serverCaCert = sourceToRecord(this.serverRootCaCert); } if (this.clientCert) { - json.clientCert = sourceToRecord(this.clientCert); + obj.clientCert = sourceToRecord(this.clientCert); } if (this.clientPrivateKey) { - json.clientKey = sourceToRecord(this.clientPrivateKey); + obj.clientKey = sourceToRecord(this.clientPrivateKey); } - return json; + return obj; } - public static fromJSON(json: ClientConfigTLSJSON): ClientConfigTLS { + public static fromObject(obj: ClientConfigTLSObject): ClientConfigTLS { return new ClientConfigTLS({ - disabled: json.disabled, - serverName: json.serverName, - serverRootCaCert: recordToSource(json.serverCaCert), - clientCert: recordToSource(json.clientCert), - clientPrivateKey: recordToSource(json.clientKey), + disabled: obj.disabled, + serverName: obj.serverName, + serverRootCaCert: recordToSource(obj.serverCaCert), + clientCert: recordToSource(obj.clientCert), + clientPrivateKey: recordToSource(obj.clientKey), }); } @@ -202,7 +218,7 @@ export class ClientConfigTLS { * or undefined if TLS is disabled. */ public toTLSConfig(): TLSConfig | undefined { - if (this.disabled) { + if (this.disabled === true) { return undefined; } @@ -329,23 +345,23 @@ export class ClientConfigProfile { return clientConfigProfileFromBridge(bridgeProfile); } - public toJSON(): ClientConfigProfileJSON { + public toObject(): ClientConfigProfileObject { return { address: this.address, namespace: this.namespace, apiKey: this.apiKey, - tls: this.tls?.toJSON(), + tls: this.tls?.toObject(), grpcMeta: this.grpcMeta, }; } - public static fromJSON(json: ClientConfigProfileJSON): ClientConfigProfile { + public static fromObject(obj: ClientConfigProfileObject): ClientConfigProfile { return new ClientConfigProfile({ - address: json.address, - namespace: json.namespace, - apiKey: json.apiKey, - tls: json.tls ? ClientConfigTLS.fromJSON(json.tls) : undefined, - grpcMeta: json.grpcMeta, + address: obj.address, + namespace: obj.namespace, + apiKey: obj.apiKey, + tls: obj.tls ? ClientConfigTLS.fromObject(obj.tls) : undefined, + grpcMeta: obj.grpcMeta, }); } @@ -368,6 +384,11 @@ export class ClientConfigProfile { * ``` */ public toClientConnectConfig(): ClientConnectConfig { + // Basic validation + if (!this.address) { + throw new Error('Address is required for client connection'); + } + const tlsConfig = this.tls?.toTLSConfig(); return { @@ -408,8 +429,8 @@ export interface LoadClientConfigOptions { overrideEnvVars?: Record; } -interface ClientConfigJSON { - profiles: Record; +interface ClientConfigObject { + profiles: Record; } /** @@ -492,18 +513,18 @@ export class ClientConfig { this.profiles = profiles; } - public toJSON(): ClientConfigJSON { - const profiles: Record = {}; + public toObject(): ClientConfigObject { + const profiles: Record = {}; for (const [name, profile] of Object.entries(this.profiles)) { - profiles[name] = profile.toJSON(); + profiles[name] = profile.toObject(); } return { profiles }; } - public static fromJSON(json: ClientConfigJSON): ClientConfig { + public static fromObject(obj: ClientConfigObject): ClientConfig { const profiles: Record = {}; - for (const [name, profile] of Object.entries(json.profiles)) { - profiles[name] = ClientConfigProfile.fromJSON(profile); + for (const [name, profile] of Object.entries(obj.profiles)) { + profiles[name] = ClientConfigProfile.fromObject(profile); } return new ClientConfig(profiles); } diff --git a/packages/core-bridge/Cargo.lock b/packages/core-bridge/Cargo.lock index 79078de1e..6a3a7d355 100644 --- a/packages/core-bridge/Cargo.lock +++ b/packages/core-bridge/Cargo.lock @@ -17,17 +17,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -170,15 +159,6 @@ version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bridge-macros" version = "0.1.0" @@ -195,12 +175,6 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.10.1" @@ -209,21 +183,11 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +checksum = "bea8dcd42434048e4f7a304411d9273a411f647446c1234a65ce0554923f4cff" dependencies = [ - "cc", - "pkg-config", + "libbz2-rs-sys", ] [[package]] @@ -259,22 +223,6 @@ dependencies = [ "serde", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - [[package]] name = "convert_case" version = "0.6.0" @@ -300,30 +248,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - [[package]] name = "crc32fast" version = "1.5.0" @@ -357,16 +281,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "darling" version = "0.20.11" @@ -416,21 +330,6 @@ dependencies = [ "parking_lot_core", ] -[[package]] -name = "deflate64" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" - -[[package]] -name = "deranged" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" -dependencies = [ - "powerfmt", -] - [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -494,36 +393,25 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - [[package]] name = "dirs" -version = "5.0.1" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.48.0", + "windows-sys 0.60.2", ] [[package]] @@ -638,6 +526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -774,16 +663,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "gethostname" version = "1.0.2" @@ -829,9 +708,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "governor" -version = "0.8.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be93b4ec2e4710b04d9264c0c7350cdd62a8c20e5e4ac732552ebb8f0debe8eb" +checksum = "444405bbb1a762387aa22dd569429533b54a1d8759d35d3b64cb39b0293eaa19" dependencies = [ "cfg-if", "dashmap", @@ -839,7 +718,7 @@ dependencies = [ "futures-timer", "futures-util", "getrandom 0.3.3", - "no-std-compat", + "hashbrown 0.15.5", "nonzero_ext", "parking_lot", "portable-atomic", @@ -892,15 +771,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "http" version = "1.3.1" @@ -1147,15 +1017,6 @@ dependencies = [ "hashbrown 0.15.5", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "instant" version = "0.1.13" @@ -1242,6 +1103,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.175" @@ -1269,6 +1136,15 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linkme" version = "0.3.33" @@ -1319,9 +1195,9 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "lru" -version = "0.13.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465" +checksum = "bfe949189f46fabb938b3a9a0be30fdd93fd8a09260da863399a8cf3db756ec8" dependencies = [ "hashbrown 0.15.5", ] @@ -1332,27 +1208,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lzma-rs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" -dependencies = [ - "byteorder", - "crc", -] - -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "matchers" version = "0.1.0" @@ -1460,12 +1315,6 @@ dependencies = [ "syn", ] -[[package]] -name = "no-std-compat" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" - [[package]] name = "nonzero_ext" version = "0.3.0" @@ -1491,12 +1340,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - [[package]] name = "num-traits" version = "0.2.19" @@ -1506,6 +1349,25 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.36.7" @@ -1662,16 +1524,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", -] - [[package]] name = "percent-encoding" version = "2.3.1" @@ -1759,12 +1611,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2116,13 +1962,13 @@ dependencies = [ [[package]] name = "redox_users" -version = "0.4.6" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 1.0.69", + "thiserror 2.0.15", ] [[package]] @@ -2396,18 +2242,28 @@ checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.224" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aaeb1e94f53b16384af593c71e20b095e958dab1d26939c1b70645c5cfbcc0b" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.224" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "32f39390fa6346e24defbcdd3d9544ba8a19985d0af74df8501fbfe9a64341ab" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.224" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "87ff78ab5e8561c9a675bfc1785cb07ae721f0ee53329a595cefd8c04c2ac4e0" dependencies = [ "proc-macro2", "quote", @@ -2428,11 +2284,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "2789234a13a53fc4be1b51ea1bab45a3c338bdb884862a257d10e5a74ae009e6" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -2447,17 +2303,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -2595,14 +2440,15 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.33.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +checksum = "07cec4dc2d2e357ca1e610cfb07de2fa7a10fc3e9fe89f72545f3d244ea87753" dependencies = [ - "core-foundation-sys", "libc", "memchr", "ntapi", + "objc2-core-foundation", + "objc2-io-kit", "windows", ] @@ -2843,25 +2689,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "time" -version = "0.3.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" - [[package]] name = "tinystr" version = "0.8.1" @@ -2954,44 +2781,42 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "ae2a4cf385da23d1d53bc15cdfa5c2109e93d8d362393c801e87da2f72f0e201" dependencies = [ - "serde", + "indexmap", + "serde_core", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.6.11" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "a197c0ec7d131bfc6f7e82c8442ba1595aeab35da7adbf05b6b73cd06a16b6be" dependencies = [ - "serde", + "serde_core", ] [[package]] -name = "toml_edit" -version = "0.22.27" +name = "toml_parser" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_writer" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" [[package]] name = "tonic" @@ -3148,12 +2973,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" -[[package]] -name = "typenum" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" - [[package]] name = "typetag" version = "0.2.20" @@ -3394,31 +3213,55 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.57.0" +version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ "windows-core", - "windows-targets 0.52.6", ] [[package]] name = "windows-core" -version = "0.57.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", + "windows-link", "windows-result", - "windows-targets 0.52.6", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", ] [[package]] name = "windows-implement" -version = "0.57.0" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", @@ -3427,9 +3270,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.57.0" +version = "0.59.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", @@ -3442,22 +3285,32 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" -version = "0.1.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-targets 0.52.6", + "windows-link", ] [[package]] -name = "windows-sys" -version = "0.48.0" +name = "windows-strings" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-targets 0.48.5", + "windows-link", ] [[package]] @@ -3487,21 +3340,6 @@ dependencies = [ "windows-targets 0.53.3", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -3536,10 +3374,13 @@ dependencies = [ ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" +name = "windows-threading" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link", +] [[package]] name = "windows_aarch64_gnullvm" @@ -3553,12 +3394,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3571,12 +3406,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3601,12 +3430,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3619,12 +3442,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3637,12 +3454,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3655,12 +3466,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3678,9 +3483,6 @@ name = "winnow" version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" -dependencies = [ - "memchr", -] [[package]] name = "wit-bindgen-rt" @@ -3707,15 +3509,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - [[package]] name = "yoke" version = "0.8.0" @@ -3786,20 +3579,6 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] [[package]] name = "zerotrie" @@ -3836,34 +3615,26 @@ dependencies = [ [[package]] name = "zip" -version = "2.4.2" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ - "aes", "arbitrary", "bzip2", - "constant_time_eq", "crc32fast", - "crossbeam-utils", - "deflate64", - "displaydoc", "flate2", - "getrandom 0.3.3", - "hmac", "indexmap", - "lzma-rs", "memchr", - "pbkdf2", - "sha1", - "thiserror 2.0.15", - "time", - "xz2", - "zeroize", "zopfli", "zstd", ] +[[package]] +name = "zlib-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" + [[package]] name = "zopfli" version = "0.8.2" diff --git a/packages/core-bridge/sdk-core b/packages/core-bridge/sdk-core index 871b320c8..7ac63c541 160000 --- a/packages/core-bridge/sdk-core +++ b/packages/core-bridge/sdk-core @@ -1 +1 @@ -Subproject commit 871b320c8f51d52cb69fcc31f9c4dcd47b9f3961 +Subproject commit 7ac63c5417445fe3ae53193ea6a011e8dfe11d15 diff --git a/packages/core-bridge/src/client.rs b/packages/core-bridge/src/client.rs index 401dc4ec2..baf33b26f 100644 --- a/packages/core-bridge/src/client.rs +++ b/packages/core-bridge/src/client.rs @@ -71,6 +71,10 @@ pub fn client_new( message: e.to_string(), field: None, })?, + Err(ClientInitError::InvalidHeaders(e)) => Err(BridgeError::TypeError { + message: e.to_string(), + field: None, + })?, }; Ok(OpaqueOutboundHandle::new(Client { diff --git a/packages/core-bridge/src/envconfig.rs b/packages/core-bridge/src/envconfig.rs index b82dd9d3a..1872db6f2 100644 --- a/packages/core-bridge/src/envconfig.rs +++ b/packages/core-bridge/src/envconfig.rs @@ -67,7 +67,7 @@ impl From for ClientConfigProfile { #[derive(TryIntoJs, Serialize)] #[serde(rename_all = "camelCase")] struct ClientConfigTls { - disabled: bool, + disabled: Option, client_cert: Option, client_key: Option, server_ca_cert: Option, diff --git a/packages/core-bridge/ts/native.ts b/packages/core-bridge/ts/native.ts index 3e5a21285..1f0328f7b 100644 --- a/packages/core-bridge/ts/native.ts +++ b/packages/core-bridge/ts/native.ts @@ -560,7 +560,7 @@ export interface ClientConfigProfile { } export interface ClientConfigTLS { - disabled: boolean; + disabled: Option; clientCert: Option; clientKey: Option; serverCaCert: Option; diff --git a/packages/test/src/test-envconfig.ts b/packages/test/src/test-envconfig.ts index ddb2b15c7..778c0fc02 100644 --- a/packages/test/src/test-envconfig.ts +++ b/packages/test/src/test-envconfig.ts @@ -554,8 +554,8 @@ test('Client config profile to/from JSON round-trip', (t) => { }), grpcMeta: { 'some-header': 'some-value' }, }); - const json = profile.toJSON(); - const back = ClientConfigProfile.fromJSON(json); + const json = profile.toObject(); + const back = ClientConfigProfile.fromObject(json); t.is(back.address, 'some-address'); t.is(back.namespace, 'some-namespace'); t.is(back.apiKey, 'some-api-key'); @@ -572,8 +572,8 @@ test('Client config to/from JSON round-trip', (t) => { default: new ClientConfigProfile({ address: 'addr', namespace: 'ns' }), custom: new ClientConfigProfile({ address: 'addr2', apiKey: 'key2', grpcMeta: { h: 'v' } }), } as any); - const json = conf.toJSON(); - const back = ClientConfig.fromJSON(json); + const json = conf.toObject(); + const back = ClientConfig.fromObject(json); t.is(back.profiles['default'].address, 'addr'); t.is(back.profiles['default'].namespace, 'ns'); t.is(back.profiles['custom'].address, 'addr2'); @@ -842,3 +842,99 @@ test('Create clients from multi-profile config', async (t) => { await env.teardown(); } }); + +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 = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(tomlNull)) }); + t.truthy(profileNull.tls); + t.is(profileNull.tls?.disabled, undefined); // disabled is null (unset) + const configNull = profileNull.toClientConnectConfig(); + 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 = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(tomlFalse)) }); + t.truthy(profileFalse.tls); + t.is(profileFalse.tls?.disabled, false); // explicitly disabled=false + const configFalse = profileFalse.toClientConnectConfig(); + 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 = ClientConfigProfile.load({ configSource: dataSource(Buffer.from(tomlTrue)) }); + t.truthy(profileTrue.tls); + t.is(profileTrue.tls?.disabled, true); // explicitly disabled=true + const configTrue = profileTrue.toClientConnectConfig(); + t.is(configTrue.connectionOptions.tls, undefined); // TLS disabled even with API key +}); + +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 } = ClientConfig.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 +}); From f4f158fbb9344fbb9311369effe7c70f332c73cf Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Tue, 16 Sep 2025 07:37:10 -0400 Subject: [PATCH 9/9] Remove useless disableFile parameter for ClientConfig.load. Remove unused native interfaces --- packages/client/src/envconfig.ts | 6 ------ packages/core-bridge/src/envconfig.rs | 13 ++++--------- packages/core-bridge/ts/native.ts | 19 ------------------- packages/test/src/test-envconfig.ts | 10 +--------- 4 files changed, 5 insertions(+), 43 deletions(-) diff --git a/packages/client/src/envconfig.ts b/packages/client/src/envconfig.ts index 12ad1ac9e..6dbdc56f0 100644 --- a/packages/client/src/envconfig.ts +++ b/packages/client/src/envconfig.ts @@ -414,11 +414,6 @@ export interface LoadClientConfigOptions { * contents of the file. */ configSource?: DataSource; - /** - * If true, file loading is disabled. This is only used when `configSource` - * is not present. - */ - disableFile?: boolean; /** If true, will error on unrecognized keys in the TOML file. */ configFileStrict?: boolean; /** @@ -492,7 +487,6 @@ export class ClientConfig { const bridgeConfig = native.loadClientConfig( path ?? null, data ?? null, - options.disableFile ?? false, options.configFileStrict ?? false, options.overrideEnvVars ?? null ); diff --git a/packages/core-bridge/src/envconfig.rs b/packages/core-bridge/src/envconfig.rs index 1872db6f2..2aca83eb7 100644 --- a/packages/core-bridge/src/envconfig.rs +++ b/packages/core-bridge/src/envconfig.rs @@ -135,7 +135,6 @@ impl From for DataSource { fn load_client_config( path: Option, data: Option>, - disable_file: bool, config_file_strict: bool, env_vars: Option>, ) -> BridgeResult { @@ -151,15 +150,11 @@ fn load_client_config( } }; - let core_config = if disable_file { - CoreClientConfig::default() - } else { - let options = envconfig::LoadClientConfigOptions { - config_source, - config_file_strict, - }; - envconfig::load_client_config(options, env_vars.as_ref())? + let options = envconfig::LoadClientConfigOptions { + config_source, + config_file_strict, }; + let core_config = envconfig::load_client_config(options, env_vars.as_ref())?; Ok(core_config.into()) } diff --git a/packages/core-bridge/ts/native.ts b/packages/core-bridge/ts/native.ts index 1f0328f7b..58aaee773 100644 --- a/packages/core-bridge/ts/native.ts +++ b/packages/core-bridge/ts/native.ts @@ -508,7 +508,6 @@ export declare function setMetricGaugeF64Value( export declare function loadClientConfig( path: Option, data: Option, - disableFile: boolean, configFileStrict: boolean, envVars: Option> ): ClientConfig; @@ -523,24 +522,6 @@ export declare function loadClientConnectConfig( envVars: Option> ): ClientConfigProfile; -export interface LoadClientConfigOptions { - path: Option; - data: Option; - disableFile: boolean; - configFileStrict: boolean; - envVars: Option>; -} - -export interface LoadClientConnectConfigOptions { - profile: Option; - path: Option; - data: Option; - disableFile: boolean; - disableEnv: boolean; - configFileStrict: boolean; - envVars: Option>; -} - export interface DataSource { path: Option; data: Option; diff --git a/packages/test/src/test-envconfig.ts b/packages/test/src/test-envconfig.ts index 778c0fc02..d0b9213af 100644 --- a/packages/test/src/test-envconfig.ts +++ b/packages/test/src/test-envconfig.ts @@ -324,10 +324,9 @@ test('Load all profiles from data', (t) => { t.is(conf.profiles['beta'].apiKey, 'beta-key'); }); -test('Load profiles from non-existent file, with disable file flag', (t) => { +test('Load profiles from non-existent file', (t) => { const conf = ClientConfig.load({ configSource: pathSource('/non_existent_file.toml'), - disableFile: true, }); t.deepEqual(conf.profiles, {}); }); @@ -340,13 +339,6 @@ test('Load all profiles with overridden file path', (t) => { }); }); -test('Load profiles with overridden file path - disabled file flag enabled', (t) => { - withTempFile(TOML_CONFIG_BASE, (filepath) => { - const conf = ClientConfig.load({ disableFile: true, overrideEnvVars: { TEMPORAL_CONFIG_FILE: filepath } }); - t.deepEqual(conf.profiles, {}); - }); -}); - test('Default profile not found returns empty profile', (t) => { const toml = dedent` [profile.existing]