diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 62284cc40..fdaa1391c 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -8,10 +8,61 @@ import { InternalLog } from '../InternalLog'; import { SdkVerbosity } from '../config/types/SdkVerbosity'; import type { DdNativeFlagsType } from '../nativeModulesTypes'; +// Imported directly (not via the module index): context matching is an internal helper, +// used here only to detect a runtime context an offline precomputed configuration cannot honor. +import { contextMatchesConfiguration } from './configuration/context'; +import { decodePrecomputedFlags, normalizeWireContext } from './configuration'; +import type { + ParsedFlagsConfiguration, + ParsedPrecomputedConfiguration +} from './configuration'; import { processEvaluationContext } from './internal'; import type { FlagCacheEntry } from './internal'; import type { JsonValue, EvaluationContext, FlagDetails } from './types'; +/** + * Error codes an offline configuration result can carry: + * - `INVALID_CONTEXT`: the active context does not match the precomputed snapshot. + * - `PROVIDER_NOT_READY`: an offline operation ran with no configuration loaded. + * - `GENERAL`: the loaded configuration is unusable (malformed/unsupported/undecodable). + */ +export type ConfigurationErrorCode = + | 'INVALID_CONTEXT' + | 'PROVIDER_NOT_READY' + | 'GENERAL'; + +/** + * Outcome of loading a configuration or reconciling it against the active evaluation context, + * returned by the offline APIs ({@link FlagsClient.setConfiguration}, + * {@link FlagsClient.setEvaluationContextWithoutFetching}, + * {@link FlagsClient.resetEvaluationContextWithoutFetching}). + * + * `'ready'` means the configuration can be served against the active context; `'error'` carries + * the precise reason so the OpenFeature provider surfaces the correct code and evaluation returns + * that code with the coded default. + * + * @internal Consumers observe OpenFeature provider events/evaluation details, not this result. + */ +export type ConfigurationResult = + | { status: 'ready' } + | { status: 'error'; errorCode: ConfigurationErrorCode }; + +/** + * The decoded outcome of the last loaded offline configuration. Load validity (`kind`) is stored + * separately from context compatibility so that later context changes reconcile against a decoded + * snapshot **without re-decoding** and can never promote an invalid load to a servable state. + * + * `'none'` = no offline configuration engaged (the online/fetch path, or nothing loaded yet). + */ +type LoadedConfigurationState = + | { kind: 'none' } + | { kind: 'invalid'; errorCode: ConfigurationErrorCode } + | { + kind: 'precomputed'; + configuration: ParsedPrecomputedConfiguration; + flags: Map; + }; + export class FlagsClient { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires private nativeFlags: DdNativeFlagsType = require('../specs/NativeDdFlags') @@ -19,9 +70,34 @@ export class FlagsClient { private clientName: string; + // The context the app explicitly set — the online fetch context, or the offline override via + // {@link setEvaluationContextWithoutFetching}. `undefined` means "no external override": an + // offline precomputed configuration is then served against its own embedded context. Tracked + // separately from {@link evaluationContext} so that adopting a configuration's embedded context + // is never mistaken for an app-set override (which would spuriously fail a later replacement). + private externalContext: EvaluationContext | undefined = undefined; + + // The effective context evaluation and exposure tracking run against: the external override + // when set, otherwise the loaded configuration's embedded context. private evaluationContext: EvaluationContext | undefined = undefined; - private flagsCache: Record = {}; + // The servable flag cache. On the online path it holds the native-fetched flags; on the + // offline path it mirrors the decoded snapshot when the configuration is servable. A Map + // (not a plain object) so a flag keyed like an inherited property ("toString", + // "__proto__") is looked up as data via `.get()` and never resolves an `Object.prototype` + // member — important now the cache can be fed from untrusted wire data. + private flagsCache: Map = new Map(); + + // The decoded outcome of the last loaded offline configuration (see the type). + private loadedConfiguration: LoadedConfigurationState = { kind: 'none' }; + + // Internal serving status. `'none'` = online path (serve `flagsCache`); `'ready'` = serve + // the offline snapshot; `'error'` = serve coded defaults with `configurationError`. + private configurationStatus: 'none' | 'ready' | 'error' = 'none'; + + private configurationError: + | { errorCode: ConfigurationErrorCode; errorMessage: string } + | undefined = undefined; constructor(clientName: string = 'default') { this.clientName = clientName; @@ -56,6 +132,24 @@ export class FlagsClient { // Make sure to process the incoming context because we don't support nested object values in context. const processedContext = processEvaluationContext(context); + // Entering online mode drops any offline overlay *before* fetching, so a failed fetch falls + // back to coded defaults rather than continuing to serve a stale offline snapshot. Using one + // client for both online and offline is unsupported (give the offline provider its own + // `clientName`), so warn when an offline configuration is discarded here. Online-only clients + // have no overlay, so this does not touch their keep-last-known-flags-on-failure behavior. + if (this.loadedConfiguration.kind !== 'none') { + InternalLog.log( + `An offline configuration was loaded for '${this.clientName}' but an online fetch was requested; discarding it and serving default values on failure. Use a separate client for the offline provider.`, + SdkVerbosity.WARN + ); + this.loadedConfiguration = { kind: 'none' }; + this.configurationStatus = 'none'; + this.configurationError = undefined; + this.flagsCache = new Map(); + this.evaluationContext = undefined; + this.externalContext = undefined; + } + try { const result = await this.nativeFlags.setEvaluationContext( this.clientName, @@ -63,9 +157,12 @@ export class FlagsClient { processedContext.attributes ?? {} ); + this.externalContext = processedContext; this.evaluationContext = processedContext; - this.flagsCache = result; + this.flagsCache = new Map(Object.entries(result)); } catch (error) { + // A failed fetch leaves the previous online cache in place (keep-last-known); any offline + // overlay was already dropped above, so no stale offline snapshot is served. if (error instanceof Error) { InternalLog.log( `Error setting flag evaluation context: ${error.message}`, @@ -77,6 +174,191 @@ export class FlagsClient { } }; + /** + * Set the evaluation context **without** fetching a configuration from the network, then + * reconcile the loaded configuration against it. + * + * The offline counterpart to {@link setEvaluationContext}: records the active context and + * reconciles with no native request. A precomputed configuration is single-subject, so a + * context that does not match the one it was computed for reconciles to an error + * (`INVALID_CONTEXT`) and evaluation serves defaults. With no configuration loaded the + * result is `PROVIDER_NOT_READY`. Intended for offline providers that own their + * configuration via `setConfiguration` and must not fetch on a context change. + * + * @param context The evaluation context to associate with the current client. + */ + setEvaluationContextWithoutFetching = ( + context: EvaluationContext + ): ConfigurationResult => { + this.externalContext = processEvaluationContext(context); + + return this.reconcile(); + }; + + /** + * Clear any externally-set evaluation context and reconcile. + * + * This is the offline counterpart to clearing/omitting an OpenFeature context: it drops the + * external override so a loaded precomputed configuration is served against **its embedded + * context** again. Clearing the override (rather than skipping) matters so that a + * configuration loaded *after* a clear is not judged against a stale override. With no + * configuration loaded the result is `PROVIDER_NOT_READY`. + */ + resetEvaluationContextWithoutFetching = (): ConfigurationResult => { + this.externalContext = undefined; + + return this.reconcile(); + }; + + /** + * Load a configuration (parsed from a `ConfigurationWire` string via + * `configurationFromString`) into the client for offline evaluation, then reconcile it + * against the active context. + * + * For a precomputed configuration this decodes the snapshot once and adopts its embedded + * evaluation context when none is set — **no network request is made**. An unusable + * configuration reconciles to an error (`GENERAL`); a context mismatch to `INVALID_CONTEXT`. + * + * @param configuration The configuration to load. + * + * @example + * ```ts + * const configuration = configurationFromString(wire); + * flagsClient.setConfiguration(configuration); + * + * const value = flagsClient.getBooleanValue('new-feature', false); + * ``` + */ + setConfiguration = ( + configuration: ParsedFlagsConfiguration + ): ConfigurationResult => { + this.loadedConfiguration = this.loadConfiguration(configuration); + + return this.reconcile(); + }; + + /** + * Decode a supplied configuration into a {@link LoadedConfigurationState} **once**, capturing + * whether the load itself is valid (independent of the active context). Later context changes + * reconcile against this stored state without re-decoding, and can never turn an invalid load + * into a servable one. + * + * FORWARD-COMPAT SEAM: when a rules-based configuration is supported, it must be handled here + * BEFORE the precomputed guard — rules are context-agnostic and must NOT be classified invalid. + */ + private loadConfiguration = ( + configuration: ParsedFlagsConfiguration + ): LoadedConfigurationState => { + const precomputed = configuration?.precomputed; + + // An empty configuration (a failed/lenient wire parse, or a wire with no precomputed + // branch) is unusable. `configurationFromString` collapses malformed input and + // unsupported versions to the same empty shape, so this is classified as `GENERAL`. + if (!precomputed) { + InternalLog.log( + `No usable precomputed configuration was provided for '${this.clientName}'.`, + SdkVerbosity.WARN + ); + return { kind: 'invalid', errorCode: 'GENERAL' }; + } + + try { + const flags = decodePrecomputedFlags(precomputed.response); + + return { kind: 'precomputed', configuration: precomputed, flags }; + } catch (error) { + // Decoding rejects unsupported payloads (e.g. obfuscated) — an unsupported kind, + // classified as `GENERAL`. + if (error instanceof Error) { + InternalLog.log( + `Unsupported flags configuration for '${this.clientName}': ${error.message}`, + SdkVerbosity.WARN + ); + } + return { kind: 'invalid', errorCode: 'GENERAL' }; + } + }; + + /** + * Reconcile the stored {@link LoadedConfigurationState} against the active evaluation context + * and (re)compute the servable flag cache and configuration status/result. + * + * The stored load validity is consulted **before** context compatibility, so a context change + * can never promote an invalid (or absent) load to `ready`. + */ + private reconcile = (): ConfigurationResult => { + const loaded = this.loadedConfiguration; + + // No offline configuration engaged: an offline operation with nothing loaded is not ready. + if (loaded.kind === 'none') { + return this.enterError( + 'PROVIDER_NOT_READY', + `The evaluation context is not usable for '${this.clientName}': no configuration is loaded. Provide a configuration via setConfiguration.` + ); + } + + // The load itself failed (malformed/unsupported/undecodable) — independent of context. + if (loaded.kind === 'invalid') { + return this.enterError( + loaded.errorCode, + `The loaded configuration for '${this.clientName}' is not usable. Provide a valid precomputed configuration.` + ); + } + + const { configuration, flags } = loaded; + + // A precomputed snapshot is bound to the subject it was computed for. If the app set an + // *external* context that does not match, it cannot be served (offline never fetches), so + // it is an error and evaluation serves coded defaults. Only an external override is checked + // here — the configuration's own embedded context (adopted below when no override is set) + // matches by construction, so replacing one snapshot with another for a different subject + // stays `ready`. The decoded snapshot is retained for a later matching context. + if ( + this.externalContext && + !contextMatchesConfiguration( + configuration.context, + this.externalContext + ) + ) { + return this.enterError( + 'INVALID_CONTEXT', + `The evaluation context does not match the precomputed configuration for '${this.clientName}'. Serving default values. Set a matching context, or use a rules-based configuration for per-context evaluation.` + ); + } + + // Serve against the external override when set, otherwise the configuration's embedded + // context (a context-agnostic configuration falls back to an empty context). + this.evaluationContext = + this.externalContext ?? this.embeddedContext(configuration); + this.flagsCache = flags; + this.configurationStatus = 'ready'; + this.configurationError = undefined; + + return { status: 'ready' }; + }; + + /** The evaluation context a precomputed configuration was computed for (empty if agnostic). */ + private embeddedContext = ( + configuration: ParsedPrecomputedConfiguration + ): EvaluationContext => { + return configuration.context + ? normalizeWireContext(configuration.context) + : { targetingKey: '', attributes: {} }; + }; + + /** Record an error status + message, clear the servable cache, and return the result. */ + private enterError = ( + errorCode: ConfigurationErrorCode, + errorMessage: string + ): ConfigurationResult => { + this.flagsCache = new Map(); + this.configurationStatus = 'error'; + this.configurationError = { errorCode, errorMessage }; + InternalLog.log(errorMessage, SdkVerbosity.WARN); + + return { status: 'error', errorCode }; + }; + private track = (flag: FlagCacheEntry, context: EvaluationContext) => { // A non-blocking call; don't await this. this.nativeFlags @@ -102,6 +384,19 @@ export class FlagsClient { defaultValue: T, type: 'boolean' | 'string' | 'number' | 'object' ): FlagDetails => { + // An offline configuration that cannot be served against the active context surfaces the + // precise error code (INVALID_CONTEXT / GENERAL / PROVIDER_NOT_READY) with the coded + // default. The OpenFeature provider maps this to a PROVIDER_ERROR / ERROR state. + if (this.configurationStatus === 'error' && this.configurationError) { + return { + key, + value: defaultValue, + reason: 'ERROR', + errorCode: this.configurationError.errorCode, + errorMessage: this.configurationError.errorMessage + }; + } + if (!this.evaluationContext) { return { key, @@ -113,7 +408,7 @@ export class FlagsClient { } // Retrieve the flag from the cache. - const flag = this.flagsCache[key]; + const flag = this.flagsCache.get(key); if (!flag) { return { diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 06903099c..87e2bb575 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -9,6 +9,7 @@ import { NativeModules } from 'react-native'; import { InternalLog } from '../../InternalLog'; import { SdkVerbosity } from '../../config/types/SdkVerbosity'; import { DdFlags } from '../DdFlags'; +import { configurationFromString } from '../configuration'; jest.spyOn(NativeModules.DdFlags, 'setEvaluationContext').mockResolvedValue({ 'test-boolean-flag': { @@ -317,4 +318,522 @@ describe('FlagsClient', () => { expect(numberFlagAsString).toBe('default'); }); }); + + const offlineFlags = { + 'offline-bool': { + variationType: 'boolean', + variationValue: true, + variationKey: 'true', + allocationKey: 'alloc-1', + reason: 'STATIC', + doLog: false, + extraLogging: {} + } + }; + + const buildConfig = ( + flags: Record, + context?: Record, + obfuscated = false + ) => + configurationFromString( + JSON.stringify({ + version: 1, + precomputed: { + response: JSON.stringify({ + data: { attributes: { obfuscated, flags } } + }), + context + } + }) + ); + + describe('setConfiguration', () => { + it('serves flags from the configuration without a native fetch', () => { + const flagsClient = DdFlags.getClient(); + + const result = flagsClient.setConfiguration( + buildConfig(offlineFlags, { + targetingKey: 'user-1', + country: 'US' + }) + ); + + expect(result).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + expect( + NativeModules.DdFlags.setEvaluationContext + ).not.toHaveBeenCalled(); + }); + + it('serves flags when an explicit matching context was set first', async () => { + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + + flagsClient.setConfiguration( + buildConfig(offlineFlags, { + targetingKey: 'user-1', + country: 'US' + }) + ); + + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + }); + + it('errors with INVALID_CONTEXT and serves defaults when an explicit context differs', async () => { + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + + const result = flagsClient.setConfiguration( + buildConfig(offlineFlags, { + targetingKey: 'user-2', + country: 'US' + }) + ); + + // Offline precomputed is single-subject: a differing context cannot be served, so + // it is an error and evaluation returns the coded default with INVALID_CONTEXT. + expect(result).toEqual({ + status: 'error', + errorCode: 'INVALID_CONTEXT' + }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'INVALID_CONTEXT' + }); + // No exposure is tracked while serving defaults. + expect( + NativeModules.DdFlags.trackEvaluation + ).not.toHaveBeenCalled(); + }); + + it('errors with GENERAL for an empty/unparseable configuration', () => { + const flagsClient = DdFlags.getClient(); + + const result = flagsClient.setConfiguration( + configurationFromString('garbage') + ); + + expect(result).toEqual({ status: 'error', errorCode: 'GENERAL' }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'GENERAL' + }); + }); + + it('errors with GENERAL for an unsupported (obfuscated) configuration', () => { + const flagsClient = DdFlags.getClient(); + + const result = flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }, true) + ); + + expect(result).toEqual({ status: 'error', errorCode: 'GENERAL' }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ errorCode: 'GENERAL' }); + }); + + it('errors with GENERAL for a structurally malformed response envelope', () => { + const flagsClient = DdFlags.getClient(); + + // A wire whose precomputed response omits `data.attributes.flags` entirely. + const wire = JSON.stringify({ + version: 1, + precomputed: { + response: JSON.stringify({ data: { attributes: {} } }), + context: { targetingKey: 'user-1' } + } + }); + + const result = flagsClient.setConfiguration( + configurationFromString(wire) + ); + + expect(result).toEqual({ status: 'error', errorCode: 'GENERAL' }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ value: false, errorCode: 'GENERAL' }); + }); + + it('serves a context-agnostic configuration (no embedded context)', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration(buildConfig(offlineFlags)); + + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + expect( + NativeModules.DdFlags.setEvaluationContext + ).not.toHaveBeenCalled(); + }); + + it('replaces a previously loaded configuration', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration( + buildConfig( + { 'flag-a': offlineFlags['offline-bool'] }, + { targetingKey: 'user-1' } + ) + ); + expect(flagsClient.getBooleanValue('flag-a', false)).toBe(true); + + flagsClient.setConfiguration( + buildConfig( + { 'flag-b': offlineFlags['offline-bool'] }, + { targetingKey: 'user-1' } + ) + ); + + expect( + flagsClient.getBooleanDetails('flag-a', false) + ).toMatchObject({ errorCode: 'FLAG_NOT_FOUND' }); + expect(flagsClient.getBooleanValue('flag-b', false)).toBe(true); + }); + + it('replaces a snapshot for one subject with a snapshot for another (no external context)', () => { + const flagsClient = DdFlags.getClient(); + + // Load A for user-1 — no external context is set, so A's embedded context is adopted. + expect( + flagsClient.setConfiguration( + buildConfig( + { 'flag-a': offlineFlags['offline-bool'] }, + { targetingKey: 'user-1' } + ) + ) + ).toEqual({ status: 'ready' }); + + // Replace with B for a DIFFERENT subject. Because the app never set an external + // context, this must adopt B's embedded context and stay ready — not error as a + // mismatch against A's adopted context. + expect( + flagsClient.setConfiguration( + buildConfig( + { 'flag-b': offlineFlags['offline-bool'] }, + { targetingKey: 'user-2' } + ) + ) + ).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('flag-b', false)).toBe(true); + expect( + flagsClient.getBooleanDetails('flag-a', false) + ).toMatchObject({ errorCode: 'FLAG_NOT_FOUND' }); + + // The exposure must be attributed to B's embedded context (user-2), not A's — a + // regression that updated the flags but kept A's context would misattribute here. + expect(NativeModules.DdFlags.trackEvaluation).toHaveBeenCalledWith( + 'default', + 'flag-b', + expect.any(Object), + 'user-2', + {} + ); + }); + + it('adopts a subject-bound snapshot loaded after a context-agnostic one (no external context)', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration(buildConfig(offlineFlags)); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + + expect( + flagsClient.setConfiguration( + buildConfig( + { 'flag-b': offlineFlags['offline-bool'] }, + { targetingKey: 'user-2' } + ) + ) + ).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('flag-b', false)).toBe(true); + }); + + it('does not resurrect a prior valid snapshot after an invalid replacement', () => { + const flagsClient = DdFlags.getClient(); + + // Valid A. + flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + + // Invalid replacement. + expect( + flagsClient.setConfiguration(configurationFromString('garbage')) + ).toEqual({ status: 'error', errorCode: 'GENERAL' }); + + // A later context change must NOT promote the invalid load back to ready. + const afterContextChange = flagsClient.setEvaluationContextWithoutFetching( + { targetingKey: 'user-1', attributes: {} } + ); + expect(afterContextChange).toEqual({ + status: 'error', + errorCode: 'GENERAL' + }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ value: false, errorCode: 'GENERAL' }); + + // A valid replacement recovers. + expect( + flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ) + ).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + }); + + it('is superseded by a later native fetch', async () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig(offlineFlags, { + targetingKey: 'user-1', + country: 'US' + }) + ); + + // A subsequent explicit context fetch replaces the offline configuration + // with the native snapshot (mocked in __mocks__/react-native.ts + above). + await flagsClient.setEvaluationContext({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ errorCode: 'FLAG_NOT_FOUND' }); + expect( + flagsClient.getBooleanValue('test-boolean-flag', false) + ).toBe(true); + }); + + it('drops the offline overlay when entering online mode, serving defaults if the fetch fails', async () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + + NativeModules.DdFlags.setEvaluationContext.mockRejectedValueOnce( + new Error('network down') + ); + await expect( + flagsClient.setEvaluationContext({ + targetingKey: 'user-1', + attributes: {} + }) + ).rejects.toThrow('network down'); + + // Using one client for both modes is unsupported: the online fetch discards the offline + // overlay up front and warns, so a failed fetch serves coded defaults rather than the + // stale offline snapshot. + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + false + ); + expect(InternalLog.log).toHaveBeenCalledWith( + expect.stringContaining('online fetch was requested'), + expect.anything() + ); + }); + }); + + describe('setEvaluationContextWithoutFetching', () => { + it('reconciles a loaded config against a matching context without fetching', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig(offlineFlags, { + targetingKey: 'user-1', + country: 'US' + }) + ); + + const result = flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + + expect(result).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + expect( + NativeModules.DdFlags.setEvaluationContext + ).not.toHaveBeenCalled(); + }); + + it('errors with INVALID_CONTEXT on a differing context change, serving defaults', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig(offlineFlags, { + targetingKey: 'user-1', + country: 'US' + }) + ); + + const result = flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: { country: 'US' } + }); + + expect(result).toEqual({ + status: 'error', + errorCode: 'INVALID_CONTEXT' + }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ value: false, errorCode: 'INVALID_CONTEXT' }); + expect( + NativeModules.DdFlags.setEvaluationContext + ).not.toHaveBeenCalled(); + // Defaults are served, so no exposure is tracked. + expect( + NativeModules.DdFlags.trackEvaluation + ).not.toHaveBeenCalled(); + }); + + it('recovers to ready when a matching context is set after a mismatch', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ); + + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: {} + }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ errorCode: 'INVALID_CONTEXT' }); + + const recovered = flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: {} + }); + + expect(recovered).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + }); + + it('returns PROVIDER_NOT_READY (not FLAG_NOT_FOUND) with no configuration loaded', () => { + const flagsClient = DdFlags.getClient(); + + const result = flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: {} + }); + + expect(result).toEqual({ + status: 'error', + errorCode: 'PROVIDER_NOT_READY' + }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'PROVIDER_NOT_READY' + }); + }); + }); + + describe('resetEvaluationContextWithoutFetching', () => { + it('re-adopts the embedded context, recovering from a mismatch', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ); + + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: {} + }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ errorCode: 'INVALID_CONTEXT' }); + + const result = flagsClient.resetEvaluationContextWithoutFetching(); + + expect(result).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + }); + + it('clears the override so a config loaded after reset adopts its embedded context', () => { + const flagsClient = DdFlags.getClient(); + + // No config yet, an external context is set, then cleared. + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: {} + }); + flagsClient.resetEvaluationContextWithoutFetching(); + + // Loading A now adopts A's embedded context (not the stale user-2 override). + const result = flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ); + + expect(result).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + }); + + it('clears the override after an invalid load so a later valid load is ready', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration(configurationFromString('garbage')); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: {} + }); + flagsClient.resetEvaluationContextWithoutFetching(); + + const result = flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ); + + expect(result).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + }); + + it('returns PROVIDER_NOT_READY with no configuration loaded', () => { + const flagsClient = DdFlags.getClient(); + + expect( + flagsClient.resetEvaluationContextWithoutFetching() + ).toEqual({ status: 'error', errorCode: 'PROVIDER_NOT_READY' }); + }); + }); }); diff --git a/packages/core/src/flags/__tests__/internal.test.ts b/packages/core/src/flags/__tests__/internal.test.ts new file mode 100644 index 000000000..eedccacbf --- /dev/null +++ b/packages/core/src/flags/__tests__/internal.test.ts @@ -0,0 +1,45 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { processEvaluationContext } from '../internal'; + +jest.mock('../../InternalLog', () => { + return { + InternalLog: { log: jest.fn() }, + DATADOG_MESSAGE_PREFIX: 'DATADOG:' + }; +}); + +describe('processEvaluationContext', () => { + it('keeps primitive attributes and drops non-primitive ones', () => { + expect( + processEvaluationContext({ + targetingKey: 'user-1', + attributes: { + country: 'US', + age: 25, + beta: true, + // Dropped: non-primitive. + nested: { a: 1 } as never + } + }) + ).toEqual({ + targetingKey: 'user-1', + attributes: { country: 'US', age: 25, beta: true } + }); + }); + + it('does not null the prototype for a "__proto__": null attribute', () => { + const result = processEvaluationContext({ + targetingKey: 'user-1', + attributes: { ['__proto__']: null } + }); + + // A plain `attributes[key] = value` would have set the object's prototype to + // null here; the Map + Object.fromEntries build keeps it a normal object. + expect(Object.getPrototypeOf(result.attributes)).toBe(Object.prototype); + }); +}); diff --git a/packages/core/src/flags/configuration/__tests__/context.test.ts b/packages/core/src/flags/configuration/__tests__/context.test.ts new file mode 100644 index 000000000..ec0155bad --- /dev/null +++ b/packages/core/src/flags/configuration/__tests__/context.test.ts @@ -0,0 +1,120 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { EvaluationContext } from '../../types'; +import { contextMatchesConfiguration, normalizeWireContext } from '../context'; + +jest.mock('../../../InternalLog', () => { + return { + InternalLog: { log: jest.fn() }, + DATADOG_MESSAGE_PREFIX: 'DATADOG:' + }; +}); + +describe('normalizeWireContext', () => { + it('maps a flat wire context to { targetingKey, attributes }', () => { + expect( + normalizeWireContext({ + targetingKey: 'user-1', + country: 'US', + age: 25 + }) + ).toEqual({ + targetingKey: 'user-1', + attributes: { country: 'US', age: 25 } + }); + }); + + it('defaults a missing targeting key to an empty string', () => { + expect(normalizeWireContext({ country: 'US' })).toEqual({ + targetingKey: '', + attributes: { country: 'US' } + }); + }); + + it('drops non-primitive attributes', () => { + expect( + normalizeWireContext({ + targetingKey: 'user-1', + country: 'US', + nested: { a: 1 } + }) + ).toEqual({ targetingKey: 'user-1', attributes: { country: 'US' } }); + }); + + it('handles a "__proto__" attribute without polluting the prototype', () => { + const normalized = normalizeWireContext({ + targetingKey: 'user-1', + ['__proto__']: 'x' + }); + + // A reserved "__proto__" attribute does not pollute the prototype: the normalized + // attributes keep `Object.prototype` and nothing leaks onto the global prototype. + // (Whether the key is retained or dropped is an implementation detail we don't assert.) + expect(Object.getPrototypeOf(normalized.attributes)).toBe( + Object.prototype + ); + expect(({} as Record).x).toBeUndefined(); + }); +}); + +describe('contextMatchesConfiguration', () => { + const active: EvaluationContext = { + targetingKey: 'user-1', + attributes: { country: 'US' } + }; + + it('matches any context when the config has no embedded context', () => { + expect(contextMatchesConfiguration(undefined, active)).toBe(true); + }); + + it('matches an equal context', () => { + expect( + contextMatchesConfiguration( + { targetingKey: 'user-1', country: 'US' }, + active + ) + ).toBe(true); + }); + + it('does not match a different targeting key', () => { + expect( + contextMatchesConfiguration( + { targetingKey: 'user-2', country: 'US' }, + active + ) + ).toBe(false); + }); + + it('does not match a different attribute value', () => { + expect( + contextMatchesConfiguration( + { targetingKey: 'user-1', country: 'CA' }, + active + ) + ).toBe(false); + }); + + it('does not match when the wire has an extra primitive attribute', () => { + expect( + contextMatchesConfiguration( + { targetingKey: 'user-1', country: 'US', plan: 'pro' }, + active + ) + ).toBe(false); + }); + + it('ignores dropped non-primitive attributes on both sides', () => { + // The nested attribute is dropped by the same normalization applied to the + // active context, so a wire context that only differs by it still matches. + expect( + contextMatchesConfiguration( + { targetingKey: 'user-1', country: 'US', nested: { a: 1 } }, + active + ) + ).toBe(true); + }); +}); diff --git a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts index 77a3ce93b..db8497286 100644 --- a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts +++ b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts @@ -225,12 +225,22 @@ describe('decodePrecomputedFlags', () => { expect(cache.get('inf')).toBeUndefined(); }); - it('returns an empty map for a structurally broken response', () => { - expect( + it.each([ + ['a null response', null], + ['a non-object response', 'nonsense'], + ['a missing data envelope', {}], + ['a missing attributes envelope', { data: {} }], + ['a missing flags map', { data: { attributes: {} } }], + ['a null flags map', { data: { attributes: { flags: null } } }], + ['an array flags map', { data: { attributes: { flags: [] } } }] + ])('throws for a structurally malformed response (%s)', (_label, input) => { + expect(() => decodePrecomputedFlags( - ({} as unknown) as Parameters[0] - ).size - ).toBe(0); + (input as unknown) as Parameters< + typeof decodePrecomputedFlags + >[0] + ) + ).toThrow(UnsupportedConfigurationError); }); it('accepts any JSON value for an object flag (array, null, primitive)', () => { diff --git a/packages/core/src/flags/configuration/context.ts b/packages/core/src/flags/configuration/context.ts new file mode 100644 index 000000000..436ad96b5 --- /dev/null +++ b/packages/core/src/flags/configuration/context.ts @@ -0,0 +1,76 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { processEvaluationContext } from '../internal'; +import type { EvaluationContext, PrimitiveValue } from '../types'; + +import type { WireEvaluationContext } from './types'; + +/** + * Normalize a wire (OpenFeature-flat) evaluation context into the SDK's internal + * `{ targetingKey, attributes }` shape, applying the **same** processing the active + * context went through (`processEvaluationContext`) so the two are comparable — the + * flat wire shape and the internal shape are otherwise never equal. + */ +export const normalizeWireContext = ( + wireContext: WireEvaluationContext +): EvaluationContext => { + const { targetingKey, ...attributes } = wireContext; + + return processEvaluationContext({ + // The wire is untrusted, so a non-string targetingKey is treated as absent. + targetingKey: typeof targetingKey === 'string' ? targetingKey : '', + // `processEvaluationContext` drops non-primitive attributes; casting here mirrors + // how the active context's attributes are typed before that same processing. + attributes: attributes as Record + }); +}; + +/** + * Whether a precomputed configuration's embedded context matches the active evaluation + * context. + * + * - A configuration with **no** embedded context is context-agnostic and matches any + * active context. + * - Otherwise the embedded context must match the active context exactly (after + * normalizing both through the same processing). + */ +export const contextMatchesConfiguration = ( + wireContext: WireEvaluationContext | undefined, + activeContext: EvaluationContext +): boolean => { + if (!wireContext) { + return true; + } + + return contextsEqual(normalizeWireContext(wireContext), activeContext); +}; + +const contextsEqual = (a: EvaluationContext, b: EvaluationContext): boolean => { + if (a.targetingKey !== b.targetingKey) { + return false; + } + + return attributesEqual(a.attributes ?? {}, b.attributes ?? {}); +}; + +/** + * Compare two attribute maps. After `processEvaluationContext`, attribute values are + * primitives, so a key-set + strict-value comparison is sufficient. + */ +const attributesEqual = ( + a: Record, + b: Record +): boolean => { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + if (aKeys.length !== bKeys.length) { + return false; + } + + return aKeys.every(key => a[key] === b[key]); +}; diff --git a/packages/core/src/flags/configuration/index.ts b/packages/core/src/flags/configuration/index.ts index 3890eefc3..6a943c9b2 100644 --- a/packages/core/src/flags/configuration/index.ts +++ b/packages/core/src/flags/configuration/index.ts @@ -14,6 +14,9 @@ export { decodePrecomputedFlags, UnsupportedConfigurationError } from './precomputed'; +// `contextMatchesConfiguration` is intentionally NOT re-exported — it is an internal +// helper consumed directly by `FlagsClient` (see its import from `./configuration/context`). +export { normalizeWireContext } from './context'; export type { ParsedFlagsConfiguration, ParsedPrecomputedConfiguration, diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index 49bba1d58..e9a453006 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -36,7 +36,8 @@ export class UnsupportedConfigurationError extends Error { * booleans, `String(...)` otherwise) because native Android exposure tracking * rebuilds the flag from the string form. * - * @throws {UnsupportedConfigurationError} if the response is obfuscated. + * @throws {UnsupportedConfigurationError} if the response is obfuscated, or its envelope is + * structurally malformed (missing/non-object `data.attributes.flags`). */ export const decodePrecomputedFlags = ( response: PrecomputedConfigurationResponse @@ -53,7 +54,17 @@ export const decodePrecomputedFlags = ( ); } - const flags = attributes?.flags ?? {}; + // Validate the response envelope before trusting it. An untrusted wire can be JSON-valid yet + // structurally malformed (a null/non-object envelope, or a `flags` that is null or an array). + // Fail predictably so the caller classifies it as an error instead of silently decoding it to + // an empty — but "ready" — configuration. A genuinely empty `flags: {}` is still accepted. + const flags = attributes?.flags; + if (!isFlagsMap(flags)) { + throw new UnsupportedConfigurationError( + "Malformed precomputed configuration: 'data.attributes.flags' must be an object." + ); + } + // A Map (returned as-is) so a pathological flag keyed "__proto__" is stored as data // and later looked up via `.get()` — never hitting the `Object.prototype` "__proto__" // setter (on write) or the prototype chain (on read) the way a plain object would. @@ -69,6 +80,11 @@ export const decodePrecomputedFlags = ( return cache; }; +// A well-formed flags container is a plain object (a possibly-empty map of flag key -> flag). +// `null`, arrays, and primitives are malformed envelopes. +const isFlagsMap = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + const toFlagCacheEntry = ( key: string, flag: unknown diff --git a/packages/core/src/flags/internal.ts b/packages/core/src/flags/internal.ts index 6d504d8cc..fc47b2109 100644 --- a/packages/core/src/flags/internal.ts +++ b/packages/core/src/flags/internal.ts @@ -30,7 +30,10 @@ export const processEvaluationContext = ( const providedAttributes: Record = context.attributes ?? {}; - const attributes: Record = {}; + // Accumulate in a Map so reserved keys such as "__proto__" are handled as data + // instead of hitting the Object.prototype setter (which would silently drop them or + // pollute the prototype). Object.fromEntries then materializes own properties safely. + const attributes = new Map(); for (const [key, value] of Object.entries(providedAttributes)) { const isPrimitiveValue = @@ -53,11 +56,11 @@ export const processEvaluationContext = ( continue; } - attributes[key] = value; + attributes.set(key, value as PrimitiveValue); } return { targetingKey, - attributes + attributes: Object.fromEntries(attributes) }; }; diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts index 12bfb18f6..ccd0fe04b 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -169,7 +169,9 @@ type FlagErrorCode = | 'PROVIDER_NOT_READY' | 'FLAG_NOT_FOUND' | 'PARSE_ERROR' - | 'TYPE_MISMATCH'; + | 'TYPE_MISMATCH' + | 'INVALID_CONTEXT' + | 'GENERAL'; /** * Detailed information about a feature flag evaluation.