From a1f9344de407279a5e69bbcc89f889c5f29f9775 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 8 Apr 2026 00:58:02 -0300 Subject: [PATCH 1/3] Add TS definitions for Configs SDK AI-Session-Id: 87d19fcc-b2ce-4a99-8f24-ec04210abecb AI-Tool: claude-code AI-Model: unknown --- types/splitio.d.ts | 111 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/types/splitio.d.ts b/types/splitio.d.ts index b8753566..5afb1daa 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -509,7 +509,7 @@ declare namespace SplitIO { /** * Metadata type for SDK update events. */ - type SdkUpdateMetadataType = 'FLAGS_UPDATE' | 'SEGMENTS_UPDATE'; + type SdkUpdateMetadataType = 'CONFIGS_UPDATE' | 'FLAGS_UPDATE' | 'SEGMENTS_UPDATE'; /** * Metadata for the ready events emitted when the SDK is ready to evaluate feature flags. @@ -2284,4 +2284,113 @@ declare namespace SplitIO { */ split(featureFlagName: string): SplitViewAsync; } + + /** + * Fallback configuration objects returned by the `client.getConfig` method when the SDK is not ready or the provided config name is not found. + */ + type FallbackConfigs = { + /** + * Fallback config for all config names. + */ + global?: Config; + /** + * Fallback configs for specific config names. It takes precedence over the global fallback config. + */ + byName?: { + [configName: string]: Config; + }; + } + + /** + * Configs SDK settings. + */ + interface ConfigsClientSettings { + /** + * Your SDK key. + * + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/management-and-administration/account-settings/api-keys/} + */ + authorizationKey: string; + /** + * Configs definitions refresh rate for polling, in seconds. + * + * @defaultValue `60` + */ + configsRefreshRate?: number; + /** + * Logging level. + * + * @defaultValue `'NONE'` + */ + logLevel?: LogLevel; + /** + * Time in seconds until SDK ready timeout is emitted. + * + * @defaultValue `10` + */ + timeout?: number; + /** + * Custom endpoints to replace the default ones used by the SDK. + */ + urls?: UrlSettings; + /** + * Fallback configuration objects returned by the `client.getConfig` method when the SDK is not ready or the provided config name is not found. + */ + fallbackConfigs?: FallbackConfigs; + } + + /** + * Target for a config evaluation. + */ + interface Target { + /** + * The key of the target. + */ + key: SplitKey; + /** + * The attributes of the target. + * + * @defaultValue `undefined` + */ + attributes?: Attributes; + } + + type JsonValue = string | number | boolean | null | JsonObject | JsonArray; + type JsonArray = JsonValue[]; + type JsonObject = { [key: string]: JsonValue; }; + + /** + * Config definition. + */ + interface Config { + /** + * The name of the variant. + */ + variant: string; + /** + * The config value, a raw JSON object. + */ + value: JsonObject; + } + + /** + * Configs SDK client interface. + */ + interface ConfigsClient extends IStatusInterface { + /** + * Destroys the client. + * + * @returns A promise that resolves once all clients are destroyed. + */ + destroy(): Promise; + /** + * Gets the config object for a given config name and optional target. If no target is provided, the default variant of the config is returned. + * + * @param name - The name of the config we want to get. + * @param target - The target of the config evaluation. + * @param options - An object of type EvaluationOptions for advanced evaluation options. + * @returns The config object. + */ + getConfig(name: string, target?: Target, options?: EvaluationOptions): Config; + } } From 7f1ca74c17e1756a27f56c020f897ec37657ba41 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 8 Apr 2026 02:45:34 -0300 Subject: [PATCH 2/3] Add fallback configuration calculator and sanitizer with corresponding tests AI-Session-Id: e9b3e072-2ec0-428a-b108-9646c6de8629 AI-Tool: claude-code AI-Model: unknown --- .../__tests__/fallback-calculator.spec.ts | 50 ++++++ .../__tests__/fallback-sanitizer.spec.ts | 147 ++++++++++++++++++ .../fallbackSanitizer/index.ts | 78 ++++++++++ .../fallbackConfigsCalculator/index.ts | 24 +++ .../fallbackTreatmentsCalculator/index.ts | 10 +- .../__tests__/clientInputValidation.spec.ts | 4 +- src/sdkClient/client.ts | 4 +- src/sdkClient/clientInputValidation.ts | 6 +- src/sdkClient/sdkClient.ts | 2 +- src/sdkFactory/index.ts | 4 +- src/sdkFactory/types.ts | 4 +- 11 files changed, 318 insertions(+), 15 deletions(-) create mode 100644 src/evaluator/fallbackConfigsCalculator/__tests__/fallback-calculator.spec.ts create mode 100644 src/evaluator/fallbackConfigsCalculator/__tests__/fallback-sanitizer.spec.ts create mode 100644 src/evaluator/fallbackConfigsCalculator/fallbackSanitizer/index.ts create mode 100644 src/evaluator/fallbackConfigsCalculator/index.ts diff --git a/src/evaluator/fallbackConfigsCalculator/__tests__/fallback-calculator.spec.ts b/src/evaluator/fallbackConfigsCalculator/__tests__/fallback-calculator.spec.ts new file mode 100644 index 00000000..239185fb --- /dev/null +++ b/src/evaluator/fallbackConfigsCalculator/__tests__/fallback-calculator.spec.ts @@ -0,0 +1,50 @@ +import { FallbackConfigsCalculator } from '../'; +import SplitIO from '../../../../types/splitio'; +import { CONTROL } from '../../../utils/constants'; + +describe('FallbackConfigsCalculator', () => { + test('returns specific fallback if config name exists', () => { + const fallbacks: SplitIO.FallbackConfigs = { + byName: { + 'configA': { variant: 'VARIANT_A', value: { key: 1 } }, + }, + }; + const calculator = FallbackConfigsCalculator(fallbacks); + const result = calculator('configA', 'label by name'); + + expect(result).toEqual({ + treatment: 'VARIANT_A', + config: { key: 1 }, + label: 'fallback - label by name', + }); + }); + + test('returns global fallback if config name is missing and global exists', () => { + const fallbacks: SplitIO.FallbackConfigs = { + byName: {}, + global: { variant: 'GLOBAL_VARIANT', value: { global: true } }, + }; + const calculator = FallbackConfigsCalculator(fallbacks); + const result = calculator('missingConfig', 'label by global'); + + expect(result).toEqual({ + treatment: 'GLOBAL_VARIANT', + config: { global: true }, + label: 'fallback - label by global', + }); + }); + + test('returns control fallback if config name and global are missing', () => { + const fallbacks: SplitIO.FallbackConfigs = { + byName: {}, + }; + const calculator = FallbackConfigsCalculator(fallbacks); + const result = calculator('missingConfig', 'label by noFallback'); + + expect(result).toEqual({ + treatment: CONTROL, + config: null, + label: 'label by noFallback', + }); + }); +}); diff --git a/src/evaluator/fallbackConfigsCalculator/__tests__/fallback-sanitizer.spec.ts b/src/evaluator/fallbackConfigsCalculator/__tests__/fallback-sanitizer.spec.ts new file mode 100644 index 00000000..12e5807b --- /dev/null +++ b/src/evaluator/fallbackConfigsCalculator/__tests__/fallback-sanitizer.spec.ts @@ -0,0 +1,147 @@ +import { isValidConfigName, isValidConfig, sanitizeFallbacks } from '../fallbackSanitizer'; +import SplitIO from '../../../../types/splitio'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; + +describe('FallbackConfigsSanitizer', () => { + const validConfig: SplitIO.Config = { variant: 'on', value: { color: 'blue' } }; + const invalidVariantConfig: SplitIO.Config = { variant: ' ', value: { color: 'blue' } }; + const invalidValueConfig = { variant: 'on', value: 'not_an_object' } as unknown as SplitIO.Config; + const fallbackMock = { + global: undefined, + byName: {} + }; + + beforeEach(() => { + loggerMock.mockClear(); + }); + + describe('isValidConfigName', () => { + test('returns true for a valid config name', () => { + expect(isValidConfigName('my_config')).toBe(true); + }); + + test('returns false for a name longer than 100 chars', () => { + const longName = 'a'.repeat(101); + expect(isValidConfigName(longName)).toBe(false); + }); + + test('returns false if the name contains spaces', () => { + expect(isValidConfigName('invalid config')).toBe(false); + }); + + test('returns false if the name is not a string', () => { + // @ts-ignore + expect(isValidConfigName(true)).toBe(false); + }); + }); + + describe('isValidConfig', () => { + test('returns true for a valid config', () => { + expect(isValidConfig(validConfig)).toBe(true); + }); + + test('returns false for null or undefined', () => { + expect(isValidConfig()).toBe(false); + expect(isValidConfig(undefined)).toBe(false); + }); + + test('returns false for a variant longer than 100 chars', () => { + const long: SplitIO.Config = { variant: 'a'.repeat(101), value: {} }; + expect(isValidConfig(long)).toBe(false); + }); + + test('returns false if variant does not match regex pattern', () => { + const invalid: SplitIO.Config = { variant: 'invalid variant!', value: {} }; + expect(isValidConfig(invalid)).toBe(false); + }); + + test('returns false if value is not an object', () => { + expect(isValidConfig(invalidValueConfig)).toBe(false); + }); + }); + + describe('sanitizeGlobal', () => { + test('returns the config if valid', () => { + expect(sanitizeFallbacks(loggerMock, { ...fallbackMock, global: validConfig })).toEqual({ ...fallbackMock, global: validConfig }); + expect(loggerMock.error).not.toHaveBeenCalled(); + }); + + test('returns undefined and logs error if variant is invalid', () => { + const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, global: invalidVariantConfig }); + expect(result).toEqual(fallbackMock); + expect(loggerMock.error).toHaveBeenCalledWith( + expect.stringContaining('Fallback configs - Discarded fallback') + ); + }); + + test('returns undefined and logs error if value is invalid', () => { + const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, global: invalidValueConfig }); + expect(result).toEqual(fallbackMock); + expect(loggerMock.error).toHaveBeenCalledWith( + expect.stringContaining('Fallback configs - Discarded fallback') + ); + }); + }); + + describe('sanitizeByName', () => { + test('returns a sanitized map with valid entries only', () => { + const input = { + valid_config: validConfig, + 'invalid config': validConfig, + bad_variant: invalidVariantConfig, + }; + + const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, byName: input }); + + expect(result).toEqual({ ...fallbackMock, byName: { valid_config: validConfig } }); + expect(loggerMock.error).toHaveBeenCalledTimes(2); // invalid config name + bad_variant + }); + + test('returns empty object if all invalid', () => { + const input = { + 'invalid config': invalidVariantConfig, + }; + + const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, byName: input }); + expect(result).toEqual(fallbackMock); + expect(loggerMock.error).toHaveBeenCalled(); + }); + + test('returns same object if all valid', () => { + const input = { + ...fallbackMock, + byName: { + config_one: validConfig, + config_two: { variant: 'valid_2', value: { key: 'val' } }, + } + }; + + const result = sanitizeFallbacks(loggerMock, input); + expect(result).toEqual(input); + expect(loggerMock.error).not.toHaveBeenCalled(); + }); + }); + + describe('sanitizeFallbacks', () => { + test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error + const result = sanitizeFallbacks(loggerMock, 'invalid_fallbacks'); + expect(result).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledWith( + 'Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties' + ); + }); + + test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error + const result = sanitizeFallbacks(loggerMock, true); + expect(result).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledWith( + 'Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties' + ); + }); + + test('sanitizes both global and byName fallbacks for empty object', () => { // @ts-expect-error + const result = sanitizeFallbacks(loggerMock, { global: {} }); + expect(result).toEqual({ global: undefined, byName: {} }); + }); + }); +}); diff --git a/src/evaluator/fallbackConfigsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackConfigsCalculator/fallbackSanitizer/index.ts new file mode 100644 index 00000000..d9f9ebb7 --- /dev/null +++ b/src/evaluator/fallbackConfigsCalculator/fallbackSanitizer/index.ts @@ -0,0 +1,78 @@ +import SplitIO from '../../../../types/splitio'; +import { ILogger } from '../../../logger/types'; +import { isObject, isString } from '../../../utils/lang'; + +enum FallbackDiscardReason { + ConfigName = 'Invalid config name (max 100 chars, no spaces)', + Variant = 'Invalid variant (max 100 chars and must match pattern)', + Value = 'Invalid value (must be an object)', +} + +const VARIANT_PATTERN = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/; + +export function isValidConfigName(name: string): boolean { + return name.length <= 100 && !name.includes(' '); +} + +export function isValidConfig(config?: SplitIO.Config): boolean { + if (!isObject(config)) return false; + if (!isString(config!.variant) || config!.variant.length > 100 || !VARIANT_PATTERN.test(config!.variant)) return false; + if (!isObject(config!.value)) return false; + return true; +} + +function sanitizeGlobal(logger: ILogger, config?: SplitIO.Config): SplitIO.Config | undefined { + if (config === undefined) return undefined; + if (!isValidConfig(config)) { + if (!isObject(config) || !isString(config!.variant) || config!.variant.length > 100 || !VARIANT_PATTERN.test(config!.variant)) { + logger.error(`Fallback configs - Discarded fallback: ${FallbackDiscardReason.Variant}`); + } else { + logger.error(`Fallback configs - Discarded fallback: ${FallbackDiscardReason.Value}`); + } + return undefined; + } + return config; +} + +function sanitizeByName( + logger: ILogger, + byNameFallbacks?: Record +): Record { + const sanitizedByName: Record = {}; + + if (!isObject(byNameFallbacks)) return sanitizedByName; + + Object.keys(byNameFallbacks!).forEach((configName) => { + const config = byNameFallbacks![configName]; + + if (!isValidConfigName(configName)) { + logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.ConfigName}`); + return; + } + + if (!isValidConfig(config)) { + if (!isObject(config) || !isString(config.variant) || config.variant.length > 100 || !VARIANT_PATTERN.test(config.variant)) { + logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.Variant}`); + } else { + logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.Value}`); + } + return; + } + + sanitizedByName[configName] = config; + }); + + return sanitizedByName; +} + +export function sanitizeFallbacks(logger: ILogger, fallbacks: SplitIO.FallbackConfigs): SplitIO.FallbackConfigs | undefined { + if (!isObject(fallbacks)) { + logger.error('Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties'); + return; + } + + return { + global: sanitizeGlobal(logger, fallbacks.global), + byName: sanitizeByName(logger, fallbacks.byName) + }; +} diff --git a/src/evaluator/fallbackConfigsCalculator/index.ts b/src/evaluator/fallbackConfigsCalculator/index.ts new file mode 100644 index 00000000..fa80e9bd --- /dev/null +++ b/src/evaluator/fallbackConfigsCalculator/index.ts @@ -0,0 +1,24 @@ +import { IFallbackCalculator } from '../fallbackTreatmentsCalculator/index'; +import { CONTROL } from '../../utils/constants'; +import SplitIO from '../../../types/splitio'; + +export const FALLBACK_PREFIX = 'fallback - '; + +export function FallbackConfigsCalculator(fallbacks: SplitIO.FallbackConfigs = {}): IFallbackCalculator { + + return (configName: string, label = '') => { + const fallback = fallbacks.byName?.[configName] || fallbacks.global; + + return fallback ? + { + treatment: fallback.variant, + config: fallback.value, + label: `${FALLBACK_PREFIX}${label}`, + } : + { + treatment: CONTROL, + config: null, + label, + }; + }; +} diff --git a/src/evaluator/fallbackTreatmentsCalculator/index.ts b/src/evaluator/fallbackTreatmentsCalculator/index.ts index 5c2b4663..fb213d25 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/index.ts @@ -1,12 +1,16 @@ -import { FallbackTreatmentConfiguration, TreatmentWithConfig } from '../../../types/splitio'; import { CONTROL } from '../../utils/constants'; import { isString } from '../../utils/lang'; +import SplitIO from '../../../types/splitio'; -export type IFallbackTreatmentsCalculator = (flagName: string, label?: string) => TreatmentWithConfig & { label: string }; +export type IFallbackCalculator = (definitionName: string, label?: string) => { + treatment: string; + config: string | null | SplitIO.JsonObject; + label: string +}; export const FALLBACK_PREFIX = 'fallback - '; -export function FallbackTreatmentsCalculator(fallbacks: FallbackTreatmentConfiguration = {}): IFallbackTreatmentsCalculator { +export function FallbackTreatmentsCalculator(fallbacks: SplitIO.FallbackTreatmentConfiguration = {}): IFallbackCalculator { return (flagName: string, label = '') => { const fallback = fallbacks.byFlag?.[flagName] || fallbacks.global; diff --git a/src/sdkClient/__tests__/clientInputValidation.spec.ts b/src/sdkClient/__tests__/clientInputValidation.spec.ts index e4de8f28..b5f18d9f 100644 --- a/src/sdkClient/__tests__/clientInputValidation.spec.ts +++ b/src/sdkClient/__tests__/clientInputValidation.spec.ts @@ -4,7 +4,7 @@ import { clientInputValidationDecorator } from '../clientInputValidation'; // Mocks import { DebugLogger } from '../../logger/browser/DebugLogger'; import { createClientMock } from './testUtils'; -import { FallbackTreatmentsCalculator, IFallbackTreatmentsCalculator } from '../../evaluator/fallbackTreatmentsCalculator'; +import { FallbackTreatmentsCalculator, IFallbackCalculator } from '../../evaluator/fallbackTreatmentsCalculator'; const settings: any = { log: DebugLogger(), @@ -14,7 +14,7 @@ const settings: any = { const EVALUATION_RESULT = 'on'; const client: any = createClientMock(EVALUATION_RESULT); -const fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator = FallbackTreatmentsCalculator(); +const fallbackTreatmentsCalculator: IFallbackCalculator = FallbackTreatmentsCalculator(); const readinessManager: any = { isReadyFromCache: () => true, diff --git a/src/sdkClient/client.ts b/src/sdkClient/client.ts index a603a76b..19f0c7dd 100644 --- a/src/sdkClient/client.ts +++ b/src/sdkClient/client.ts @@ -35,7 +35,7 @@ function stringify(options?: SplitIO.EvaluationOptions) { * Creator of base client with getTreatments and track methods. */ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | SplitIO.IAsyncClient { - const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker, telemetryTracker, fallbackTreatmentsCalculator } = params; + const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker, telemetryTracker, fallbackCalculator } = params; const { log, mode } = settings; const isAsync = isConsumerMode(mode); @@ -147,7 +147,7 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl let { treatment, label, config = null } = evaluation; if (treatment === CONTROL) { - const fallbackTreatment = fallbackTreatmentsCalculator(featureFlagName, label); + const fallbackTreatment = fallbackCalculator(featureFlagName, label); treatment = fallbackTreatment.treatment; label = fallbackTreatment.label; config = fallbackTreatment.config; diff --git a/src/sdkClient/clientInputValidation.ts b/src/sdkClient/clientInputValidation.ts index 9ed2a722..e8db5b0b 100644 --- a/src/sdkClient/clientInputValidation.ts +++ b/src/sdkClient/clientInputValidation.ts @@ -19,13 +19,13 @@ import { ISettings } from '../types'; import SplitIO from '../../types/splitio'; import { isConsumerMode } from '../utils/settingsValidation/mode'; import { validateFlagSets } from '../utils/settingsValidation/splitFilters'; -import { IFallbackTreatmentsCalculator } from '../evaluator/fallbackTreatmentsCalculator'; +import { IFallbackCalculator } from '../evaluator/fallbackTreatmentsCalculator'; /** * Decorator that validates the input before actually executing the client methods. * We should "guard" the client here, while not polluting the "real" implementation of those methods. */ -export function clientInputValidationDecorator(settings: ISettings, client: TClient, readinessManager: IReadinessManager, fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator): TClient { +export function clientInputValidationDecorator(settings: ISettings, client: TClient, readinessManager: IReadinessManager, fallbackCalculator: IFallbackCalculator): TClient { const { log, mode } = settings; const isAsync = isConsumerMode(mode); @@ -66,7 +66,7 @@ export function clientInputValidationDecorator, - fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator + fallbackCalculator: IFallbackCalculator } export interface ISdkFactoryContextSync extends ISdkFactoryContext { From f41c1de044154094dd56d5d22ce0de6db8702762 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Wed, 8 Apr 2026 14:16:15 -0300 Subject: [PATCH 3/3] Refactor clientInputValidation tests to remove unnecessary type annotation AI-Session-Id: e9b3e072-2ec0-428a-b108-9646c6de8629 AI-Tool: claude-code AI-Model: unknown --- src/sdkClient/__tests__/clientInputValidation.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sdkClient/__tests__/clientInputValidation.spec.ts b/src/sdkClient/__tests__/clientInputValidation.spec.ts index b5f18d9f..3f87782b 100644 --- a/src/sdkClient/__tests__/clientInputValidation.spec.ts +++ b/src/sdkClient/__tests__/clientInputValidation.spec.ts @@ -4,7 +4,7 @@ import { clientInputValidationDecorator } from '../clientInputValidation'; // Mocks import { DebugLogger } from '../../logger/browser/DebugLogger'; import { createClientMock } from './testUtils'; -import { FallbackTreatmentsCalculator, IFallbackCalculator } from '../../evaluator/fallbackTreatmentsCalculator'; +import { FallbackTreatmentsCalculator } from '../../evaluator/fallbackTreatmentsCalculator'; const settings: any = { log: DebugLogger(), @@ -14,7 +14,7 @@ const settings: any = { const EVALUATION_RESULT = 'on'; const client: any = createClientMock(EVALUATION_RESULT); -const fallbackTreatmentsCalculator: IFallbackCalculator = FallbackTreatmentsCalculator(); +const fallbackTreatmentsCalculator = FallbackTreatmentsCalculator(); const readinessManager: any = { isReadyFromCache: () => true,