From ca198f554cd61eb0e8cdeb71743124c40f0293df Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 16:10:32 -0400 Subject: [PATCH 1/3] feat(flags): FlagsClient.setConfiguration with context matching (FFL-2688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add offline configuration loading to the core FlagsClient: - setConfiguration(config): decodes a precomputed configuration into the flag cache. When no context is set yet, it adopts the configuration's embedded context (implicit set) so offline evaluation works with no native fetch. When a context is already set, the configuration's context must match it. - Context matching: new configuration/context.ts normalizes the flat wire context through the same processEvaluationContext as the active context, then deep-compares — a config with no embedded context is context-agnostic. - Mismatch -> serve no values and report INVALID_CONTEXT (added to FlagErrorCode); empty/unsupported/invalid config -> PROVIDER_NOT_READY (not FLAG_NOT_FOUND). - A subsequent native setEvaluationContext fetch supersedes the offline config. Tests cover context normalization/matching and the setConfiguration flows (implicit-set no-fetch, match/mismatch against an explicit context, invalid config, and fetch supersession). fetchPolicy (PR3) and the OpenFeature provider lifecycle (PR4) are unchanged here. The parse-failure vs valid-but-empty distinction (L2) remains for a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 143 ++++++++++++++++++ .../src/flags/__tests__/FlagsClient.test.ts | 129 ++++++++++++++++ .../configuration/__tests__/context.test.ts | 105 +++++++++++++ .../core/src/flags/configuration/context.ts | 75 +++++++++ .../core/src/flags/configuration/index.ts | 1 + packages/core/src/flags/types.ts | 3 +- 6 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/flags/configuration/__tests__/context.test.ts create mode 100644 packages/core/src/flags/configuration/context.ts diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 62284cc40..3d69d950a 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -8,10 +8,23 @@ import { InternalLog } from '../InternalLog'; import { SdkVerbosity } from '../config/types/SdkVerbosity'; import type { DdNativeFlagsType } from '../nativeModulesTypes'; +import { + contextMatchesConfiguration, + decodePrecomputedFlags, + normalizeWireContext +} from './configuration'; +import type { ParsedFlagsConfiguration } from './configuration'; import { processEvaluationContext } from './internal'; import type { FlagCacheEntry } from './internal'; import type { JsonValue, EvaluationContext, FlagDetails } from './types'; +/** + * Tracks how a configuration supplied via {@link FlagsClient.setConfiguration} relates + * to the active evaluation context. `'none'` means no offline configuration is engaged + * (the online/fetch path is in effect). + */ +type ConfigurationStatus = 'none' | 'ready' | 'mismatch' | 'invalid'; + export class FlagsClient { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires private nativeFlags: DdNativeFlagsType = require('../specs/NativeDdFlags') @@ -23,6 +36,12 @@ export class FlagsClient { private flagsCache: Record = {}; + private loadedConfiguration: + | ParsedFlagsConfiguration + | undefined = undefined; + + private configurationStatus: ConfigurationStatus = 'none'; + constructor(clientName: string = 'default') { this.clientName = clientName; } @@ -65,6 +84,11 @@ export class FlagsClient { this.evaluationContext = processedContext; this.flagsCache = result; + + // An explicit online fetch supersedes any previously loaded offline + // configuration, so drop the offline overlay to keep state coherent. + this.loadedConfiguration = undefined; + this.configurationStatus = 'none'; } catch (error) { if (error instanceof Error) { InternalLog.log( @@ -77,6 +101,105 @@ export class FlagsClient { } }; + /** + * Load a configuration (parsed from a `ConfigurationWire` string via + * `configurationFromString`) into the client for offline evaluation. + * + * For a precomputed configuration this populates the flag cache and, when no context + * has been set yet, adopts the configuration's embedded evaluation context — **no + * network request is made**. If a context has already been set, the configuration's + * context must match it; otherwise the client serves no values and reports + * `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): void => { + this.loadedConfiguration = configuration; + this.applyConfiguration(); + }; + + /** + * Reconcile the loaded configuration against the active evaluation context and + * (re)compute the servable flag cache and configuration status. + */ + private applyConfiguration = (): void => { + const precomputed = this.loadedConfiguration?.precomputed; + + // Only precomputed configurations are supported for now. An empty configuration + // (an invalid/failed wire parse, or a server-only wire) is not usable. + if (!precomputed) { + this.flagsCache = {}; + this.configurationStatus = 'invalid'; + InternalLog.log( + `No usable precomputed configuration was provided for '${this.clientName}'.`, + SdkVerbosity.WARN + ); + return; + } + + let decoded: Record; + try { + decoded = decodePrecomputedFlags(precomputed.response); + } catch (error) { + this.flagsCache = {}; + this.configurationStatus = 'invalid'; + if (error instanceof Error) { + InternalLog.log( + `Unsupported flags configuration for '${this.clientName}': ${error.message}`, + SdkVerbosity.WARN + ); + } + return; + } + + // If no context has been set yet, adopt the configuration's embedded context + // (implicit set — no native fetch). A context-agnostic configuration falls back + // to an empty context so evaluation can proceed. + if (!this.evaluationContext) { + if (precomputed.context) { + this.evaluationContext = normalizeWireContext( + precomputed.context + ); + } else { + InternalLog.log( + `The provided configuration for '${this.clientName}' has no embedded context; treating it as context-agnostic.`, + SdkVerbosity.WARN + ); + this.evaluationContext = { targetingKey: '', attributes: {} }; + } + + this.flagsCache = decoded; + this.configurationStatus = 'ready'; + return; + } + + // A context is already set — the configuration must match it. + if ( + contextMatchesConfiguration( + precomputed.context, + this.evaluationContext + ) + ) { + this.flagsCache = decoded; + this.configurationStatus = 'ready'; + } else { + this.flagsCache = {}; + this.configurationStatus = 'mismatch'; + InternalLog.log( + `The provided configuration for '${this.clientName}' does not match the active evaluation context.`, + SdkVerbosity.WARN + ); + } + }; + private track = (flag: FlagCacheEntry, context: EvaluationContext) => { // A non-blocking call; don't await this. this.nativeFlags @@ -102,6 +225,26 @@ export class FlagsClient { defaultValue: T, type: 'boolean' | 'string' | 'number' | 'object' ): FlagDetails => { + if (this.configurationStatus === 'mismatch') { + return { + key, + value: defaultValue, + reason: 'ERROR', + errorCode: 'INVALID_CONTEXT', + errorMessage: `The loaded configuration for '${this.clientName}' does not match the active evaluation context.` + }; + } + + if (this.configurationStatus === 'invalid') { + return { + key, + value: defaultValue, + reason: 'ERROR', + errorCode: 'PROVIDER_NOT_READY', + errorMessage: `The loaded configuration for '${this.clientName}' is not usable. Provide a valid precomputed configuration.` + }; + } + if (!this.evaluationContext) { return { key, diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 06903099c..fb9b082ba 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,132 @@ describe('FlagsClient', () => { expect(numberFlagAsString).toBe('default'); }); }); + + describe('setConfiguration', () => { + const offlineFlags = { + 'offline-bool': { + variationType: 'boolean', + variationValue: true, + variationKey: 'true', + allocationKey: 'alloc-1', + reason: 'STATIC', + doLog: false, + extraLogging: {} + } + }; + + const buildConfig = ( + flags: Record, + context?: Record + ) => + configurationFromString( + JSON.stringify({ + version: 1, + precomputed: { + response: JSON.stringify({ + data: { attributes: { obfuscated: false, flags } } + }), + context + } + }) + ); + + it('serves flags from the configuration without a native fetch', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration( + buildConfig(offlineFlags, { + targetingKey: 'user-1', + country: 'US' + }) + ); + + 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('returns INVALID_CONTEXT when the config does not match an explicit context', async () => { + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + + flagsClient.setConfiguration( + buildConfig(offlineFlags, { + targetingKey: 'user-2', + country: 'US' + }) + ); + + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'INVALID_CONTEXT' + }); + }); + + it('returns PROVIDER_NOT_READY for an empty/invalid configuration', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration(configurationFromString('garbage')); + + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'PROVIDER_NOT_READY' + }); + }); + + 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); + }); + }); }); 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..de03670f3 --- /dev/null +++ b/packages/core/src/flags/configuration/__tests__/context.test.ts @@ -0,0 +1,105 @@ +/* + * 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' } }); + }); +}); + +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/context.ts b/packages/core/src/flags/configuration/context.ts new file mode 100644 index 000000000..06044db02 --- /dev/null +++ b/packages/core/src/flags/configuration/context.ts @@ -0,0 +1,75 @@ +/* + * 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({ + targetingKey: 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..0ce6673c6 100644 --- a/packages/core/src/flags/configuration/index.ts +++ b/packages/core/src/flags/configuration/index.ts @@ -14,6 +14,7 @@ export { decodePrecomputedFlags, UnsupportedConfigurationError } from './precomputed'; +export { contextMatchesConfiguration, normalizeWireContext } from './context'; export type { ParsedFlagsConfiguration, ParsedPrecomputedConfiguration, diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts index 12bfb18f6..e451f4e72 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -169,7 +169,8 @@ type FlagErrorCode = | 'PROVIDER_NOT_READY' | 'FLAG_NOT_FOUND' | 'PARSE_ERROR' - | 'TYPE_MISMATCH'; + | 'TYPE_MISMATCH' + | 'INVALID_CONTEXT'; /** * Detailed information about a feature flag evaluation. From b1d185834e1ff03b60d03c382f2314d78cad2603 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 17:41:20 -0400 Subject: [PATCH 2/3] refactor(flags): address PR2 review (rules seam, proto-safe context, tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on PR2: - #7: build the processed evaluation context with a Map + Object.fromEntries so reserved keys like "__proto__" (notably a "__proto__": null attribute, which a plain assignment would have used to null the object's prototype) are handled as data without prototype manipulation. - #1: mark the forward-compat seam in applyConfiguration so a future server/rules configuration is not rejected as "invalid" (rules configs are context-agnostic). - #3/#4/#5: document the deferred seams — fetch-failure/staleness fallback and the NEVER re-match belong to the fetch-policy step; the PROVIDER_ERROR provider event belongs to the OpenFeature provider step. - #6: add tests for a context-agnostic configuration, configuration replacement, and the __proto__ context-attribute / proto-null safety cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 19 ++++++++ .../src/flags/__tests__/FlagsClient.test.ts | 37 +++++++++++++++ .../core/src/flags/__tests__/internal.test.ts | 45 +++++++++++++++++++ .../configuration/__tests__/context.test.ts | 13 ++++++ packages/core/src/flags/internal.ts | 9 ++-- 5 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/flags/__tests__/internal.test.ts diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 3d69d950a..c0d1c317e 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -87,9 +87,18 @@ export class FlagsClient { // An explicit online fetch supersedes any previously loaded offline // configuration, so drop the offline overlay to keep state coherent. + // + // PROVISIONAL: this reflects the current fetch-always default. When the + // `NEVER` fetch policy lands, that path must skip the fetch and re-run + // `applyConfiguration()` against the new context instead of dropping the + // loaded configuration. this.loadedConfiguration = undefined; this.configurationStatus = 'none'; } catch (error) { + // NOTE: a failed fetch leaves any previously loaded offline configuration in + // place, so the client may keep serving it (and attribute exposures to its + // context). Fetch-failure/staleness fallback is deferred to the fetch-policy + // step. if (error instanceof Error) { InternalLog.log( `Error setting flag evaluation context: ${error.message}`, @@ -135,6 +144,10 @@ export class FlagsClient { // Only precomputed configurations are supported for now. An empty configuration // (an invalid/failed wire parse, or a server-only wire) is not usable. + // + // FORWARD-COMPAT SEAM: when the `server`/rules branch is parsed (see + // `ParsedFlagsConfiguration`), it must be handled BEFORE this guard — a rules + // configuration is context-agnostic and must NOT be rejected here as `invalid`. if (!precomputed) { this.flagsCache = {}; this.configurationStatus = 'invalid'; @@ -191,6 +204,9 @@ export class FlagsClient { this.flagsCache = decoded; this.configurationStatus = 'ready'; } else { + // Per spec, a context mismatch must not serve values. This also blocks a + // previously-fetched online cache until a matching config or a new fetch; + // the fetch policy (a later step) formalizes online/offline precedence. this.flagsCache = {}; this.configurationStatus = 'mismatch'; InternalLog.log( @@ -235,6 +251,9 @@ export class FlagsClient { }; } + // A loaded-but-unusable configuration surfaces as PROVIDER_NOT_READY at the + // evaluation layer (distinct from FLAG_NOT_FOUND). The dedicated PROVIDER_ERROR + // provider event is wired by the OpenFeature provider in a later step. if (this.configurationStatus === 'invalid') { return { key, diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index fb9b082ba..efcca17ed 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -422,6 +422,43 @@ describe('FlagsClient', () => { }); }); + 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('is superseded by a later native fetch', async () => { const flagsClient = DdFlags.getClient(); flagsClient.setConfiguration( 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 index de03670f3..f75891993 100644 --- a/packages/core/src/flags/configuration/__tests__/context.test.ts +++ b/packages/core/src/flags/configuration/__tests__/context.test.ts @@ -44,6 +44,19 @@ describe('normalizeWireContext', () => { }) ).toEqual({ targetingKey: 'user-1', attributes: { country: 'US' } }); }); + + it('handles a "__proto__" attribute without polluting the prototype', () => { + const normalized = normalizeWireContext({ + targetingKey: 'user-1', + ['__proto__']: 'x' + }); + + // The reserved key is safely discarded; the prototype is untouched. + expect(Object.getPrototypeOf(normalized.attributes)).toBe( + Object.prototype + ); + expect(({} as Record).x).toBeUndefined(); + }); }); describe('contextMatchesConfiguration', () => { 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) }; }; From 3bd5e6eb197c02f05f6f1620a001efcaddfe1800 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 18:05:25 -0400 Subject: [PATCH 3/3] feat(flags): add FlagsClient.setEvaluationContextWithoutFetching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offline counterpart to setEvaluationContext: records the active context and reconciles a configuration loaded via setConfiguration against it (context matching for a precomputed config) with no native request. This is the core seam an offline OpenFeature provider uses to update context on initialize/onContextChange without ever fetching from the CDN — replacing the previously-planned fetchPolicy flag with a provider-level choice. Tests cover the no-fetch reconcile (match serves, context-change mismatch -> INVALID_CONTEXT) and assert the native fetch is never called. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 24 ++++++ .../src/flags/__tests__/FlagsClient.test.ts | 74 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index c0d1c317e..392c87c6e 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -110,6 +110,30 @@ export class FlagsClient { } }; + /** + * Set the evaluation context **without** fetching a configuration from the network, + * then reconcile any configuration loaded via {@link setConfiguration} against it. + * + * This is the offline counterpart to {@link setEvaluationContext}: it records the + * active context and re-evaluates the loaded configuration (context matching, for a + * precomputed configuration) with no native request. It is intended for offline + * providers that own their configuration via `setConfiguration` and must not fetch on + * a context change. With no configuration loaded yet, the context is simply recorded. + * + * @param context The evaluation context to associate with the current client. + */ + setEvaluationContextWithoutFetching = ( + context: EvaluationContext + ): void => { + this.evaluationContext = processEvaluationContext(context); + + // Re-evaluate a loaded offline configuration against the new context. Readiness + // when no configuration is loaded yet is the provider's concern. + if (this.loadedConfiguration) { + this.applyConfiguration(); + } + }; + /** * Load a configuration (parsed from a `ConfigurationWire` string via * `configurationFromString`) into the client for offline evaluation. diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index efcca17ed..f49fea1d9 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -483,4 +483,78 @@ describe('FlagsClient', () => { ).toBe(true); }); }); + + describe('setEvaluationContextWithoutFetching', () => { + const offlineFlags = { + 'offline-bool': { + variationType: 'boolean', + variationValue: true, + variationKey: 'true', + allocationKey: 'alloc-1', + reason: 'STATIC', + doLog: false, + extraLogging: {} + } + }; + + const buildConfig = (context: Record) => + configurationFromString( + JSON.stringify({ + version: 1, + precomputed: { + response: JSON.stringify({ + data: { + attributes: { + obfuscated: false, + flags: offlineFlags + } + } + }), + context + } + }) + ); + + it('reconciles a loaded config against the context without fetching', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig({ targetingKey: 'user-1', country: 'US' }) + ); + + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + expect( + NativeModules.DdFlags.setEvaluationContext + ).not.toHaveBeenCalled(); + }); + + it('re-validates on a context change (mismatch -> INVALID_CONTEXT), still no fetch', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig({ targetingKey: 'user-1', country: 'US' }) + ); + + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: { country: 'US' } + }); + + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'INVALID_CONTEXT' + }); + expect( + NativeModules.DdFlags.setEvaluationContext + ).not.toHaveBeenCalled(); + }); + }); });